zakat
xxx

"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف

 _____     _         _     _     _ _                          
|__  /__ _| | ____ _| |_  | |   (_) |__  _ __ __ _ _ __ _   _ 
  / // _` | |/ / _` | __| | |   | | '_ \| '__/ _` | '__| | | |
 / /| (_| |   < (_| | |_  | |___| | |_) | | | (_| | |  | |_| |
/____\__,_|_|\_\__,_|\__| |_____|_|_.__/|_|  \__,_|_|   \__, |
... Never Trust, Always Verify ...                       |___/ 

This library provides the ZakatLibrary classes, functions for tracking and calculating Zakat.


# ☪️ Zakat: A Python Library for Islamic Financial Management ** **We must pay Zakat if the remaining of every transaction reaches the Haul and Nisab limits** ** ###### [PROJECT UNDER ACTIVE R&D]

CodeRabbit Pull Request Reviews ar

Zakat is a user-friendly Python library designed to simplify the tracking and calculation of Zakat, a fundamental pillar of Islamic finance. Whether you're an individual or an organization, Zakat provides the tools to accurately manage your Zakat obligations.

Get Started:

Install the Zakat library using pip:

pip install zakat
Testing
python -c "import zakat, sys; sys.exit(zakat.test())"
Example
from zakat import tracker, time
from datetime import datetime
from dateutil.relativedelta import relativedelta

ledger = tracker(':memory:') # or './zakat_db'

# Add balance (track a transaction)
ledger.track(10000, "Initial deposit") # default account is 1
# or
pocket_account_id = ledger.create_account("pocket")
ledger.track(
    10000, # amount
    "Initial deposit", # description
    account=pocket_account_id,
    created_time_ns=time(datetime.now()),
)
# or old transaction
box_ref = ledger.track(
    10000, # amount
    "Initial deposit", # description
    account=ledger.create_account("bunker"),
    created_time_ns=time(datetime.now() - relativedelta(years=1)),
)

# Note: If any account does not exist it will be automatically created.

# Subtract balance
ledger.subtract(500, "Plummer maintenance expense") # default account is 1
# or
subtract_report = ledger.subtract(
    500, # amount
    "Internet monthly subscription", # description
    account=pocket_account_id,
    created_time_ns=time(datetime.now()),
)

# Transfer balance
bank_account_id = ledger.create_account("bank")
ledger.transfer(100, pocket_account_id, bank_account_id) # default time is now
# or
transfer_report = ledger.transfer(
    100,
    from_account=pocket_account_id,
    to_account=ledger.create_account("safe"),
    created_time_ns=time(datetime.now()),
)
# or
bank_usd_account_id = ledger.create_account("bank (USD)")
ledger.exchange(bank_usd_account_id, rate=3.75) # suppose current currency is SAR rate=1
ledger.transfer(375, pocket_account_id, bank_usd_account_id) # This time exchange rates considered

# Note: The age of balances in all transactions are preserved while transfering.

# Estimate Zakat (generate a report)
zakat_report = ledger.check(silver_gram_price=2.5)


# Perform Zakat (Apply Zakat)
# discount from the same accounts if Zakat applicable individually or collectively
ledger.zakat(zakat_report) # --> True
# or Collect all Zakat and discount from selected accounts
parts = ledger.build_payment_parts(zakat_report.summary.total_zakatable_amount)
# modify `parts` to distribute your Zakat on selected accounts
ledger.zakat(zakat_report, parts) # --> False
Vault data structure:

The main data storage file system on disk is JSON format, but it is shown here in JSON format for data generated by the example above (note: times will be different if re-applied by yourself):

{
  "account": {
    "1": {
      "balance": 950000,
      "created": 63879017256646705000,
      "name": "",
      "box": {
        "63879017256646705152": {
          "capital": 1000000,
          "rest": 950000,
          "zakat": {
            "count": 0,
            "last": 0,
            "total": 0
          }
        }
      },
      "count": 2,
      "log": {
        "63879017256646705152": {
          "value": 1000000,
          "desc": "Initial deposit",
          "ref": null,
          "file": {}
        },
        "63879017256648155136": {
          "value": -50000,
          "desc": "Plummer maintenance expense",
          "ref": null,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    },
    "63879017256647188480": {
      "balance": 892500,
      "created": 63879017256647230000,
      "name": "pocket",
      "box": {
        "63879017256647409664": {
          "capital": 1000000,
          "rest": 892500,
          "zakat": {
            "count": 0,
            "last": 0,
            "total": 0
          }
        }
      },
      "count": 5,
      "log": {
        "63879017256647409664": {
          "value": 1000000,
          "desc": "Initial deposit",
          "ref": null,
          "file": {}
        },
        "63879017256648392704": {
          "value": -50000,
          "desc": "Internet monthly subscription",
          "ref": null,
          "file": {}
        },
        "63879017256648802304": {
          "value": -10000,
          "desc": "",
          "ref": null,
          "file": {}
        },
        "63879017256649555968": {
          "value": -10000,
          "desc": "",
          "ref": null,
          "file": {}
        },
        "63879017256650096640": {
          "value": -37500,
          "desc": "",
          "ref": null,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    },
    "63879017256647622656": {
      "balance": 975000,
      "created": 63879017256647655000,
      "name": "bunker",
      "box": {
        "63847481256647794688": {
          "capital": 1000000,
          "rest": 975000,
          "zakat": {
            "count": 1,
            "last": 63879017256650820000,
            "total": 25000
          }
        }
      },
      "count": 2,
      "log": {
        "63847481256647794688": {
          "value": 1000000,
          "desc": "Initial deposit",
          "ref": null,
          "file": {}
        },
        "63879017256650932224": {
          "value": -25000,
          "desc": "zakat-زكاة",
          "ref": 63847481256647795000,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    },
    "63879017256648605696": {
      "balance": 10000,
      "created": 63879017256648640000,
      "name": "bank",
      "box": {
        "63879017256647409664": {
          "capital": 10000,
          "rest": 10000,
          "zakat": {
            "count": 0,
            "last": 0,
            "total": 0
          }
        }
      },
      "count": 1,
      "log": {
        "63879017256647409664": {
          "value": 10000,
          "desc": "",
          "ref": null,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    },
    "63879017256649383936": {
      "balance": 10000,
      "created": 63879017256649425000,
      "name": "safe",
      "box": {
        "63879017256647409664": {
          "capital": 10000,
          "rest": 10000,
          "zakat": {
            "count": 0,
            "last": 0,
            "total": 0
          }
        }
      },
      "count": 1,
      "log": {
        "63879017256647409664": {
          "value": 10000,
          "desc": "",
          "ref": null,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    },
    "63879017256649859072": {
      "balance": 10000,
      "created": 63879017256649890000,
      "name": "bank (USD)",
      "box": {
        "63879017256647409664": {
          "capital": 10000,
          "rest": 10000,
          "zakat": {
            "count": 0,
            "last": 0,
            "total": 0
          }
        }
      },
      "count": 1,
      "log": {
        "63879017256647409664": {
          "value": 10000,
          "desc": "",
          "ref": null,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    }
  },
  "exchange": {
    "63879017256649859072": {
      "63879017256649998336": {
        "rate": 3.75,
        "description": null,
        "time": 63879017256650000000
      }
    }
  },
  "history": {
    "63879017256646787072": {
      "63879017256646885376": {
        "action": "CREATE",
        "account": "1",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      },
      "63879017256647065600": {
        "action": "LOG",
        "account": "1",
        "ref": 63879017256646705000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      },
      "63879017256647139328": {
        "action": "TRACK",
        "account": "1",
        "ref": 63879017256646705000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      }
    },
    "63879017256647254016": {
      "63879017256647303168": {
        "action": "CREATE",
        "account": "63879017256647188480",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      }
    },
    "63879017256647352320": {
      "63879017256647385088": {
        "action": "NAME",
        "account": "63879017256647188480",
        "ref": null,
        "file": null,
        "key": null,
        "value": "",
        "math": null
      }
    },
    "63879017256647442432": {
      "63879017256647540736": {
        "action": "LOG",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      },
      "63879017256647589888": {
        "action": "TRACK",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      }
    },
    "63879017256647680000": {
      "63879017256647712768": {
        "action": "CREATE",
        "account": "63879017256647622656",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      }
    },
    "63879017256647745536": {
      "63879017256647778304": {
        "action": "NAME",
        "account": "63879017256647622656",
        "ref": null,
        "file": null,
        "key": null,
        "value": "",
        "math": null
      }
    },
    "63879017256647999488": {
      "63879017256648081408": {
        "action": "LOG",
        "account": "63879017256647622656",
        "ref": 63847481256647795000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      },
      "63879017256648122368": {
        "action": "TRACK",
        "account": "63879017256647622656",
        "ref": 63847481256647795000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      }
    },
    "63879017256648187904": {
      "63879017256648294400": {
        "action": "LOG",
        "account": "1",
        "ref": 63879017256648155000,
        "file": null,
        "key": null,
        "value": -50000,
        "math": null
      },
      "63879017256648351744": {
        "action": "SUBTRACT",
        "account": "1",
        "ref": 63879017256646705000,
        "file": null,
        "key": null,
        "value": 50000,
        "math": null
      }
    },
    "63879017256648425472": {
      "63879017256648531968": {
        "action": "LOG",
        "account": "63879017256647188480",
        "ref": 63879017256648390000,
        "file": null,
        "key": null,
        "value": -50000,
        "math": null
      },
      "63879017256648564736": {
        "action": "SUBTRACT",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 50000,
        "math": null
      }
    },
    "63879017256648663040": {
      "63879017256648704000": {
        "action": "CREATE",
        "account": "63879017256648605696",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      }
    },
    "63879017256648736768": {
      "63879017256648761344": {
        "action": "NAME",
        "account": "63879017256648605696",
        "ref": null,
        "file": null,
        "key": null,
        "value": "",
        "math": null
      }
    },
    "63879017256648818688": {
      "63879017256649031680": {
        "action": "LOG",
        "account": "63879017256647188480",
        "ref": 63879017256648800000,
        "file": null,
        "key": null,
        "value": -10000,
        "math": null
      },
      "63879017256649072640": {
        "action": "SUBTRACT",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      },
      "63879017256649285632": {
        "action": "LOG",
        "account": "63879017256648605696",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      },
      "63879017256649334784": {
        "action": "TRACK",
        "account": "63879017256648605696",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      }
    },
    "63879017256649441280": {
      "63879017256649482240": {
        "action": "CREATE",
        "account": "63879017256649383936",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      }
    },
    "63879017256649515008": {
      "63879017256649539584": {
        "action": "NAME",
        "account": "63879017256649383936",
        "ref": null,
        "file": null,
        "key": null,
        "value": "",
        "math": null
      }
    },
    "63879017256649580544": {
      "63879017256649662464": {
        "action": "LOG",
        "account": "63879017256647188480",
        "ref": 63879017256649560000,
        "file": null,
        "key": null,
        "value": -10000,
        "math": null
      },
      "63879017256649695232": {
        "action": "SUBTRACT",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      },
      "63879017256649801728": {
        "action": "LOG",
        "account": "63879017256649383936",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      },
      "63879017256649826304": {
        "action": "TRACK",
        "account": "63879017256649383936",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      }
    },
    "63879017256649900032": {
      "63879017256649932800": {
        "action": "CREATE",
        "account": "63879017256649859072",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      }
    },
    "63879017256649957376": {
      "63879017256649973760": {
        "action": "NAME",
        "account": "63879017256649859072",
        "ref": null,
        "file": null,
        "key": null,
        "value": "",
        "math": null
      }
    },
    "63879017256650022912": {
      "63879017256650047488": {
        "action": "EXCHANGE",
        "account": "63879017256649859072",
        "ref": 63879017256650000000,
        "file": null,
        "key": null,
        "value": 3.75,
        "math": null
      }
    },
    "63879017256650121216": {
      "63879017256650203136": {
        "action": "LOG",
        "account": "63879017256647188480",
        "ref": 63879017256650100000,
        "file": null,
        "key": null,
        "value": -37500,
        "math": null
      },
      "63879017256650227712": {
        "action": "SUBTRACT",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 37500,
        "math": null
      },
      "63879017256650334208": {
        "action": "LOG",
        "account": "63879017256649859072",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      },
      "63879017256650366976": {
        "action": "TRACK",
        "account": "63879017256649859072",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      }
    },
    "63879017256650760192": {
      "63879017256650801152": {
        "action": "REPORT",
        "account": null,
        "ref": 63879017256650785000,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      },
      "63879017256650866688": {
        "action": "ZAKAT",
        "account": "63879017256647622656",
        "ref": 63847481256647795000,
        "file": null,
        "key": "last",
        "value": 0,
        "math": "EQUAL"
      },
      "63879017256650891264": {
        "action": "ZAKAT",
        "account": "63879017256647622656",
        "ref": 63847481256647795000,
        "file": null,
        "key": "total",
        "value": 25000,
        "math": "ADDITION"
      },
      "63879017256650907648": {
        "action": "ZAKAT",
        "account": "63879017256647622656",
        "ref": 63847481256647795000,
        "file": null,
        "key": "count",
        "value": 1,
        "math": "ADDITION"
      },
      "63879017256650964992": {
        "action": "LOG",
        "account": "63879017256647622656",
        "ref": 63879017256650930000,
        "file": null,
        "key": null,
        "value": -25000,
        "math": null
      }
    }
  },
  "lock": null,
  "report": {
    "63879017256650784768": {
      "valid": true,
      "summary": {
        "total_wealth": 2900000,
        "num_wealth_items": 6,
        "num_zakatable_items": 1,
        "total_zakatable_amount": 1000000,
        "total_zakat_due": 25000
      },
      "plan": {
        "63879017256647622656": [
          {
            "box": {
              "capital": 1000000,
              "rest": 975000,
              "zakat": {
                "count": 1,
                "last": 63879017256650820000,
                "total": 25000
              }
            },
            "log": {
              "value": 1000000,
              "desc": "Initial deposit",
              "ref": null,
              "file": {}
            },
            "exchange": {
              "rate": 1,
              "description": null,
              "time": 63879017256650555000
            },
            "below_nisab": false,
            "total": 25000,
            "count": 1,
            "ref": 63847481256647795000
          }
        ]
      }
    }
  }
}

Key Features:

  • Transaction Tracking: Easily record both income and expenses with detailed descriptions, ensuring comprehensive financial records.

  • Automated Zakat Calculation: Automatically calculate Zakat due based on the Nisab (minimum threshold), Haul (time cycles) and the current market price of silver, simplifying compliance with Islamic financial principles.

  • Customizable "Nisab": Set your own "Nisab" value based on your preferred calculation method or personal financial situation.

  • Customizable "Haul": Set your own "Haul" cycle based on your preferred calender method or personal financial situation.

  • Multiple Accounts: Manage Zakat for different assets or accounts separately for greater financial clarity.

  • Import/Export: Seamlessly import transaction data from CSV files [experimental] and export calculated Zakat reports in JSON format for further analysis or record-keeping.

  • Data Persistence: Securely save and load your Zakat tracker data for continued use across sessions.

  • History Tracking: Optionally enable a detailed history of actions for transparency and review (can be disabled optionally).

Benefits:

  • Accurate Zakat Calculation: Ensure precise calculation of Zakat obligations, promoting financial responsibility and spiritual well-being.

  • Streamlined Financial Management: Simplify the management of your finances by keeping track of transactions and Zakat calculations in one place.

  • Enhanced Transparency: Maintain a clear record of your financial activities and Zakat payments for greater accountability and peace of mind.

  • User-Friendly: Easily navigate through the library's intuitive interface and functionalities, even without extensive technical knowledge.

Customizable:

  • Tailor the library's settings (e.g., Nisab value and Haul cycles) to your specific needs and preferences.

Who Can Benefit:

  • Individuals: Effectively manage personal finances and fulfill Zakat obligations.

  • Organizations: Streamline Zakat calculation and distribution for charitable projects and initiatives.

  • Islamic Financial Institutions: Integrate Zakat into existing systems for enhanced financial management and reporting.

Documentation

Videos:

Explore the documentation, source code and examples to begin tracking your Zakat and achieving financial peace of mind in accordance with Islamic principles.

  1"""
  2"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف
  3
  4```
  5 _____     _         _     _     _ _                          
  6|__  /__ _| | ____ _| |_  | |   (_) |__  _ __ __ _ _ __ _   _ 
  7  / // _` | |/ / _` | __| | |   | | '_ \| '__/ _` | '__| | | |
  8 / /| (_| |   < (_| | |_  | |___| | |_) | | | (_| | |  | |_| |
  9/____\__,_|_|\_\__,_|\__| |_____|_|_.__/|_|  \__,_|_|   \__, |
 10... Never Trust, Always Verify ...                       |___/ 
 11```
 12
 13This library provides the ZakatLibrary classes, functions for tracking and calculating Zakat.
 14
 15.. include:: ../README.md
 16"""
 17# Importing necessary classes and functions from the main module
 18from zakat.zakat_tracker import (
 19    Time,
 20    SizeInfo,
 21    FileInfo,
 22    FileStats,
 23    TimeSummary,
 24    Transaction,
 25    DailyRecords,
 26    Timeline,
 27    ImportStatistics,
 28    CSVRecord,
 29    ImportReport,
 30    ZakatTracker,
 31    AccountID,
 32    AccountDetails,
 33    Timestamp,
 34    Box,
 35    Log,
 36    Account,
 37    Exchange,
 38    History,
 39    Vault,
 40    AccountPaymentPart,
 41    PaymentParts,
 42    SubtractAge,
 43    SubtractAges,
 44    SubtractReport,
 45    TransferTime,
 46    TransferTimes,
 47    TransferRecord,
 48    TransferReport,
 49    BoxPlan,
 50    ZakatSummary,
 51    ZakatReport,
 52    test,
 53    Action,
 54    JSONEncoder,
 55    JSONDecoder,
 56    MathOperation,
 57    WeekDay,
 58    StrictDataclass,
 59    ImmutableWithSelectiveFreeze,
 60    Backup,
 61)
 62
 63from zakat.file_server import (
 64    start_file_server,
 65    find_available_port,
 66    FileType,
 67)
 68
 69# Shortcuts
 70time = Time.time
 71time_to_datetime = Time.time_to_datetime
 72tracker = ZakatTracker
 73
 74# Version information for the module
 75__version__ = ZakatTracker.Version()
 76__all__ = [
 77    "Time",
 78    "time",
 79    "time_to_datetime",
 80    "tracker",
 81    "SizeInfo",
 82    "FileInfo",
 83    "FileStats",
 84    "TimeSummary",
 85    "Transaction",
 86    "DailyRecords",
 87    "Timeline",
 88    "ImportStatistics",
 89    "CSVRecord",
 90    "ImportReport",
 91    "ZakatTracker",
 92    "AccountID",
 93    "AccountDetails",
 94    "Timestamp",
 95    "Box",
 96    "Log",
 97    "Account",
 98    "Exchange",
 99    "History",
100    "Vault",
101    "AccountPaymentPart",
102    "PaymentParts",
103    "SubtractAge",
104    "SubtractAges",
105    "SubtractReport",
106    "TransferTime",
107    "TransferTimes",
108    "TransferRecord",
109    "TransferReport",
110    "BoxPlan",
111    "ZakatSummary",
112    "ZakatReport",
113    "test",
114    "Action",
115    "JSONEncoder",
116    "JSONDecoder",
117    "MathOperation",
118    "WeekDay",
119    "start_file_server",
120    "find_available_port",
121    "FileType",
122    "StrictDataclass",
123    "ImmutableWithSelectiveFreeze",
124    "Backup",
125]
class Time:
1101class Time:
1102    """
1103    Utility class for generating and manipulating nanosecond-precision timestamps.
1104
1105    This class provides static methods for converting between datetime objects and
1106    nanosecond-precision timestamps, ensuring uniqueness and monotonicity.
1107    """
1108    __last_time_ns = None
1109    __time_diff_ns = None
1110
1111    @staticmethod
1112    def minimum_time_diff_ns() -> tuple[int, int]:
1113        """
1114        Calculates the minimum time difference between two consecutive calls to
1115        `Time._time()` in nanoseconds.
1116
1117        This method is used internally to determine the minimum granularity of
1118        time measurements within the system.
1119
1120        Returns:
1121        - tuple[int, int]:
1122            - The minimum time difference in nanoseconds.
1123            - The number of iterations required to measure the difference.
1124        """
1125        i = 0
1126        x = y = Time._time()
1127        while x == y:
1128            y = Time._time()
1129            i += 1
1130        return y - x, i
1131
1132    @staticmethod
1133    def _time(now: Optional[datetime.datetime] = None) -> Timestamp:
1134        """
1135        Internal method to generate a nanosecond-precision timestamp from a datetime object.
1136
1137        Parameters:
1138        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
1139        If not provided, the current datetime is used.
1140
1141        Returns:
1142        - int: The timestamp in nanoseconds since the epoch (January 1, 1AD).
1143        """
1144        if now is None:
1145            now = datetime.datetime.now()
1146        ns_in_day = (now - now.replace(
1147            hour=0,
1148            minute=0,
1149            second=0,
1150            microsecond=0,
1151        )).total_seconds() * 10 ** 9
1152        return Timestamp(int(now.toordinal() * 86_400_000_000_000 + ns_in_day))
1153
1154    @staticmethod
1155    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1156        """
1157        Generates a unique, monotonically increasing timestamp based on the provided
1158        datetime object or the current datetime.
1159
1160        This method ensures that timestamps are unique even if called in rapid succession
1161        by introducing a small delay if necessary, based on the system's minimum
1162        time resolution.
1163
1164        Parameters:
1165        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1166
1167        Returns:
1168        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1169        """
1170        new_time = Time._time(now)
1171        if Time.__last_time_ns is None:
1172            Time.__last_time_ns = new_time
1173            return new_time
1174        while new_time == Time.__last_time_ns:
1175            if Time.__time_diff_ns is None:
1176                diff, _ = Time.minimum_time_diff_ns()
1177                Time.__time_diff_ns = math.ceil(diff)
1178            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1179            new_time = Time._time()
1180        Time.__last_time_ns = new_time
1181        return new_time
1182
1183    @staticmethod
1184    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1185        """
1186        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1187        back to a datetime object.
1188
1189        Parameters:
1190        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1191
1192        Returns:
1193        - datetime.datetime: The corresponding datetime object.
1194        """
1195        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1196        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1197        return datetime.datetime.combine(d, datetime.time()) + t
1198
1199    @staticmethod
1200    def duration_from_nanoseconds(ns: int,
1201                                  show_zeros_in_spoken_time: bool = False,
1202                                  spoken_time_separator=',',
1203                                  millennia: str = 'Millennia',
1204                                  century: str = 'Century',
1205                                  years: str = 'Years',
1206                                  days: str = 'Days',
1207                                  hours: str = 'Hours',
1208                                  minutes: str = 'Minutes',
1209                                  seconds: str = 'Seconds',
1210                                  milli_seconds: str = 'MilliSeconds',
1211                                  micro_seconds: str = 'MicroSeconds',
1212                                  nano_seconds: str = 'NanoSeconds',
1213                                  ) -> tuple:
1214        """
1215        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1216        Convert NanoSeconds to Human Readable Time Format.
1217        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1218        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1219        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1220        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1221
1222        INPUT : ms (AKA: MilliSeconds)
1223        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1224        OUTPUT Variables: time_lapsed, spoken_time
1225
1226        Example  Input: duration_from_nanoseconds(ns)
1227        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
1228        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')
1229        duration_from_nanoseconds(1234567890123456789012)
1230        """
1231        us, ns = divmod(ns, 1000)
1232        ms, us = divmod(us, 1000)
1233        s, ms = divmod(ms, 1000)
1234        m, s = divmod(s, 60)
1235        h, m = divmod(m, 60)
1236        d, h = divmod(h, 24)
1237        y, d = divmod(d, 365)
1238        c, y = divmod(y, 100)
1239        n, c = divmod(c, 10)
1240        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}'
1241        spoken_time_part = []
1242        if n > 0 or show_zeros_in_spoken_time:
1243            spoken_time_part.append(f'{n: 3d} {millennia}')
1244        if c > 0 or show_zeros_in_spoken_time:
1245            spoken_time_part.append(f'{c: 4d} {century}')
1246        if y > 0 or show_zeros_in_spoken_time:
1247            spoken_time_part.append(f'{y: 3d} {years}')
1248        if d > 0 or show_zeros_in_spoken_time:
1249            spoken_time_part.append(f'{d: 4d} {days}')
1250        if h > 0 or show_zeros_in_spoken_time:
1251            spoken_time_part.append(f'{h: 2d} {hours}')
1252        if m > 0 or show_zeros_in_spoken_time:
1253            spoken_time_part.append(f'{m: 2d} {minutes}')
1254        if s > 0 or show_zeros_in_spoken_time:
1255            spoken_time_part.append(f'{s: 2d} {seconds}')
1256        if ms > 0 or show_zeros_in_spoken_time:
1257            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
1258        if us > 0 or show_zeros_in_spoken_time:
1259            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
1260        if ns > 0 or show_zeros_in_spoken_time:
1261            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
1262        return time_lapsed, spoken_time_separator.join(spoken_time_part)
1263
1264    @staticmethod
1265    def test(debug: bool = False):
1266        """
1267        Performs unit tests to verify the correctness of the `Time` class methods.
1268
1269        This method checks the conversion between datetime objects and timestamps,
1270        ensuring accuracy and consistency across various date ranges.
1271
1272        Parameters:
1273        - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
1274        """
1275        test_cases = [
1276            datetime.datetime(1, 1, 1),
1277            datetime.datetime(1970, 1, 1),
1278            datetime.datetime(1969, 12, 31),
1279            datetime.datetime.now(),
1280            datetime.datetime(9999, 12, 31, 23, 59, 59),
1281        ]
1282
1283        for test_date in test_cases:
1284            timestamp = Time.time(test_date)
1285            converted = Time.time_to_datetime(timestamp)
1286            if debug:
1287                print(f'{timestamp} <=> {converted}')
1288            assert timestamp > 0
1289            assert test_date.year == converted.year
1290            assert test_date.month == converted.month
1291            assert test_date.day == converted.day
1292            assert test_date.hour == converted.hour
1293            assert test_date.minute == converted.minute
1294            assert test_date.second in [converted.second - 1, converted.second, converted.second + 1]
1295
1296        # sanity check - convert date since 1AD to 9999AD
1297
1298        for year in range(1, 10_000):
1299            ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f'))
1300            date = Time.time_to_datetime(ns)
1301            if debug:
1302                print(date, date.microsecond)
1303            assert ns > 0
1304            assert date.year == year
1305            assert date.month == 12
1306            assert date.day == 30
1307            assert date.hour == 18
1308            assert date.minute == 30
1309            assert date.second in [44, 45]
1310            #assert date.microsecond == 906030

Utility class for generating and manipulating nanosecond-precision timestamps.

This class provides static methods for converting between datetime objects and nanosecond-precision timestamps, ensuring uniqueness and monotonicity.

@staticmethod
def minimum_time_diff_ns() -> tuple[int, int]:
1111    @staticmethod
1112    def minimum_time_diff_ns() -> tuple[int, int]:
1113        """
1114        Calculates the minimum time difference between two consecutive calls to
1115        `Time._time()` in nanoseconds.
1116
1117        This method is used internally to determine the minimum granularity of
1118        time measurements within the system.
1119
1120        Returns:
1121        - tuple[int, int]:
1122            - The minimum time difference in nanoseconds.
1123            - The number of iterations required to measure the difference.
1124        """
1125        i = 0
1126        x = y = Time._time()
1127        while x == y:
1128            y = Time._time()
1129            i += 1
1130        return y - x, i

Calculates the minimum time difference between two consecutive calls to Time._time() in nanoseconds.

This method is used internally to determine the minimum granularity of time measurements within the system.

Returns:

  • tuple[int, int]:
    • The minimum time difference in nanoseconds.
    • The number of iterations required to measure the difference.
@staticmethod
def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1154    @staticmethod
1155    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1156        """
1157        Generates a unique, monotonically increasing timestamp based on the provided
1158        datetime object or the current datetime.
1159
1160        This method ensures that timestamps are unique even if called in rapid succession
1161        by introducing a small delay if necessary, based on the system's minimum
1162        time resolution.
1163
1164        Parameters:
1165        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1166
1167        Returns:
1168        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1169        """
1170        new_time = Time._time(now)
1171        if Time.__last_time_ns is None:
1172            Time.__last_time_ns = new_time
1173            return new_time
1174        while new_time == Time.__last_time_ns:
1175            if Time.__time_diff_ns is None:
1176                diff, _ = Time.minimum_time_diff_ns()
1177                Time.__time_diff_ns = math.ceil(diff)
1178            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1179            new_time = Time._time()
1180        Time.__last_time_ns = new_time
1181        return new_time

Generates a unique, monotonically increasing timestamp based on the provided datetime object or the current datetime.

This method ensures that timestamps are unique even if called in rapid succession by introducing a small delay if necessary, based on the system's minimum time resolution.

Parameters:

  • now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.

Returns:

  • Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
@staticmethod
def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1183    @staticmethod
1184    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1185        """
1186        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1187        back to a datetime object.
1188
1189        Parameters:
1190        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1191
1192        Returns:
1193        - datetime.datetime: The corresponding datetime object.
1194        """
1195        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1196        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1197        return datetime.datetime.combine(d, datetime.time()) + t

Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) back to a datetime object.

Parameters:

  • ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).

Returns:

  • datetime.datetime: The corresponding datetime object.
@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:
1199    @staticmethod
1200    def duration_from_nanoseconds(ns: int,
1201                                  show_zeros_in_spoken_time: bool = False,
1202                                  spoken_time_separator=',',
1203                                  millennia: str = 'Millennia',
1204                                  century: str = 'Century',
1205                                  years: str = 'Years',
1206                                  days: str = 'Days',
1207                                  hours: str = 'Hours',
1208                                  minutes: str = 'Minutes',
1209                                  seconds: str = 'Seconds',
1210                                  milli_seconds: str = 'MilliSeconds',
1211                                  micro_seconds: str = 'MicroSeconds',
1212                                  nano_seconds: str = 'NanoSeconds',
1213                                  ) -> tuple:
1214        """
1215        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1216        Convert NanoSeconds to Human Readable Time Format.
1217        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1218        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1219        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1220        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1221
1222        INPUT : ms (AKA: MilliSeconds)
1223        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1224        OUTPUT Variables: time_lapsed, spoken_time
1225
1226        Example  Input: duration_from_nanoseconds(ns)
1227        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
1228        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')
1229        duration_from_nanoseconds(1234567890123456789012)
1230        """
1231        us, ns = divmod(ns, 1000)
1232        ms, us = divmod(us, 1000)
1233        s, ms = divmod(ms, 1000)
1234        m, s = divmod(s, 60)
1235        h, m = divmod(m, 60)
1236        d, h = divmod(h, 24)
1237        y, d = divmod(d, 365)
1238        c, y = divmod(y, 100)
1239        n, c = divmod(c, 10)
1240        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}'
1241        spoken_time_part = []
1242        if n > 0 or show_zeros_in_spoken_time:
1243            spoken_time_part.append(f'{n: 3d} {millennia}')
1244        if c > 0 or show_zeros_in_spoken_time:
1245            spoken_time_part.append(f'{c: 4d} {century}')
1246        if y > 0 or show_zeros_in_spoken_time:
1247            spoken_time_part.append(f'{y: 3d} {years}')
1248        if d > 0 or show_zeros_in_spoken_time:
1249            spoken_time_part.append(f'{d: 4d} {days}')
1250        if h > 0 or show_zeros_in_spoken_time:
1251            spoken_time_part.append(f'{h: 2d} {hours}')
1252        if m > 0 or show_zeros_in_spoken_time:
1253            spoken_time_part.append(f'{m: 2d} {minutes}')
1254        if s > 0 or show_zeros_in_spoken_time:
1255            spoken_time_part.append(f'{s: 2d} {seconds}')
1256        if ms > 0 or show_zeros_in_spoken_time:
1257            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
1258        if us > 0 or show_zeros_in_spoken_time:
1259            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
1260        if ns > 0 or show_zeros_in_spoken_time:
1261            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
1262        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 test(debug: bool = False):
1264    @staticmethod
1265    def test(debug: bool = False):
1266        """
1267        Performs unit tests to verify the correctness of the `Time` class methods.
1268
1269        This method checks the conversion between datetime objects and timestamps,
1270        ensuring accuracy and consistency across various date ranges.
1271
1272        Parameters:
1273        - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
1274        """
1275        test_cases = [
1276            datetime.datetime(1, 1, 1),
1277            datetime.datetime(1970, 1, 1),
1278            datetime.datetime(1969, 12, 31),
1279            datetime.datetime.now(),
1280            datetime.datetime(9999, 12, 31, 23, 59, 59),
1281        ]
1282
1283        for test_date in test_cases:
1284            timestamp = Time.time(test_date)
1285            converted = Time.time_to_datetime(timestamp)
1286            if debug:
1287                print(f'{timestamp} <=> {converted}')
1288            assert timestamp > 0
1289            assert test_date.year == converted.year
1290            assert test_date.month == converted.month
1291            assert test_date.day == converted.day
1292            assert test_date.hour == converted.hour
1293            assert test_date.minute == converted.minute
1294            assert test_date.second in [converted.second - 1, converted.second, converted.second + 1]
1295
1296        # sanity check - convert date since 1AD to 9999AD
1297
1298        for year in range(1, 10_000):
1299            ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f'))
1300            date = Time.time_to_datetime(ns)
1301            if debug:
1302                print(date, date.microsecond)
1303            assert ns > 0
1304            assert date.year == year
1305            assert date.month == 12
1306            assert date.day == 30
1307            assert date.hour == 18
1308            assert date.minute == 30
1309            assert date.second in [44, 45]
1310            #assert date.microsecond == 906030

Performs unit tests to verify the correctness of the Time class methods.

This method checks the conversion between datetime objects and timestamps, ensuring accuracy and consistency across various date ranges.

Parameters:

  • debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
@staticmethod
def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1154    @staticmethod
1155    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1156        """
1157        Generates a unique, monotonically increasing timestamp based on the provided
1158        datetime object or the current datetime.
1159
1160        This method ensures that timestamps are unique even if called in rapid succession
1161        by introducing a small delay if necessary, based on the system's minimum
1162        time resolution.
1163
1164        Parameters:
1165        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1166
1167        Returns:
1168        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1169        """
1170        new_time = Time._time(now)
1171        if Time.__last_time_ns is None:
1172            Time.__last_time_ns = new_time
1173            return new_time
1174        while new_time == Time.__last_time_ns:
1175            if Time.__time_diff_ns is None:
1176                diff, _ = Time.minimum_time_diff_ns()
1177                Time.__time_diff_ns = math.ceil(diff)
1178            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1179            new_time = Time._time()
1180        Time.__last_time_ns = new_time
1181        return new_time

Generates a unique, monotonically increasing timestamp based on the provided datetime object or the current datetime.

This method ensures that timestamps are unique even if called in rapid succession by introducing a small delay if necessary, based on the system's minimum time resolution.

Parameters:

  • now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.

Returns:

  • Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
@staticmethod
def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1183    @staticmethod
1184    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1185        """
1186        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1187        back to a datetime object.
1188
1189        Parameters:
1190        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1191
1192        Returns:
1193        - datetime.datetime: The corresponding datetime object.
1194        """
1195        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1196        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1197        return datetime.datetime.combine(d, datetime.time()) + t

Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) back to a datetime object.

Parameters:

  • ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).

Returns:

  • datetime.datetime: The corresponding datetime object.
tracker = <class 'ZakatTracker'>
@dataclasses.dataclass
class SizeInfo(zakat.StrictDataclass):
808@dataclasses.dataclass
809class SizeInfo(StrictDataclass):
810    """
811    Represents size information in bytes and human-readable format.
812    
813    Attributes:
814    - bytes (float): The size in bytes.
815    - human_readable (str): The human-readable representation of the size.
816    """
817    bytes: float
818    human_readable: str

Represents size information in bytes and human-readable format.

Attributes:

  • bytes (float): The size in bytes.
  • human_readable (str): The human-readable representation of the size.
SizeInfo(bytes: float, human_readable: str)
bytes: float
human_readable: str
@dataclasses.dataclass
class FileInfo(zakat.StrictDataclass):
821@dataclasses.dataclass
822class FileInfo(StrictDataclass):
823    """
824    Represents information about a file.
825    
826    Attributes:
827    - type (str): The type of the file.
828    - path (str): The full path to the file.
829    - exists (bool): A boolean indicating whether the file exists.
830    - size (int): The size of the file in bytes.
831    - human_readable_size (str): The human-readable representation of the file size.
832    """
833    type: str
834    path: str
835    exists: bool
836    size: int
837    human_readable_size: str

Represents information about a file.

Attributes:

  • type (str): The type of the file.
  • path (str): The full path to the file.
  • exists (bool): A boolean indicating whether the file exists.
  • size (int): The size of the file in bytes.
  • human_readable_size (str): The human-readable representation of the file size.
FileInfo( type: str, path: str, exists: bool, size: int, human_readable_size: str)
type: str
path: str
exists: bool
size: int
human_readable_size: str
@dataclasses.dataclass
class FileStats(zakat.StrictDataclass):
840@dataclasses.dataclass
841class FileStats(StrictDataclass):
842    """
843    Represents statistics related to file storage.
844    
845    Attributes:
846    - ram (:class:`SizeInfo`): Information about the RAM usage.
847    - database (:class:`SizeInfo`): Information about the database size.
848    """
849    ram: SizeInfo
850    database: SizeInfo

Represents statistics related to file storage.

Attributes:

  • ram (SizeInfo): Information about the RAM usage.
  • database (SizeInfo): Information about the database size.
FileStats( ram: SizeInfo, database: SizeInfo)
ram: SizeInfo
database: SizeInfo
@dataclasses.dataclass
class TimeSummary(zakat.StrictDataclass):
853@dataclasses.dataclass
854class TimeSummary(StrictDataclass):
855    """Summary of positive, negative, and total values over a period."""
856    positive: int = 0
857    negative: int = 0
858    total: int = 0

Summary of positive, negative, and total values over a period.

TimeSummary(positive: int = 0, negative: int = 0, total: int = 0)
positive: int = 0
negative: int = 0
total: int = 0
@dataclasses.dataclass
class Transaction(zakat.StrictDataclass):
861@dataclasses.dataclass
862class Transaction(StrictDataclass):
863    """Represents a single transaction record."""
864    account: str
865    account_id: AccountID
866    desc: str
867    file: dict[Timestamp, str]
868    value: int
869    time: Timestamp
870    transfer: bool

Represents a single transaction record.

Transaction( account: str, account_id: AccountID, desc: str, file: dict[Timestamp, str], value: int, time: Timestamp, transfer: bool)
account: str
account_id: AccountID
desc: str
file: dict[Timestamp, str]
value: int
time: Timestamp
transfer: bool
@dataclasses.dataclass
class DailyRecords(zakat.TimeSummary, zakat.StrictDataclass):
873@dataclasses.dataclass
874class DailyRecords(TimeSummary, StrictDataclass):
875    """Represents the records for a single day, including a summary and a list of transactions."""
876    rows: list[Transaction] = dataclasses.field(default_factory=list)

Represents the records for a single day, including a summary and a list of transactions.

DailyRecords( positive: int = 0, negative: int = 0, total: int = 0, rows: list[Transaction] = <factory>)
rows: list[Transaction]
Inherited Members
TimeSummary
positive
negative
total
@dataclasses.dataclass
class Timeline(zakat.StrictDataclass):
879@dataclasses.dataclass
880class Timeline(StrictDataclass):
881    """Aggregated transaction data organized by daily, weekly, monthly, and yearly summaries."""
882    daily: dict[str, DailyRecords] = dataclasses.field(default_factory=dict)
883    weekly: dict[datetime.datetime, TimeSummary] = dataclasses.field(default_factory=dict)
884    monthly: dict[str, TimeSummary] = dataclasses.field(default_factory=dict)
885    yearly: dict[int, TimeSummary] = dataclasses.field(default_factory=dict)

Aggregated transaction data organized by daily, weekly, monthly, and yearly summaries.

Timeline( daily: dict[str, DailyRecords] = <factory>, weekly: dict[datetime.datetime, TimeSummary] = <factory>, monthly: dict[str, TimeSummary] = <factory>, yearly: dict[int, TimeSummary] = <factory>)
daily: dict[str, DailyRecords]
weekly: dict[datetime.datetime, TimeSummary]
monthly: dict[str, TimeSummary]
yearly: dict[int, TimeSummary]
@dataclasses.dataclass
class ImportStatistics(zakat.StrictDataclass):
752@dataclasses.dataclass
753class ImportStatistics(StrictDataclass):
754    """
755    Statistics summarizing the results of an import operation.
756
757    Attributes:
758    - created (int): The number of new records successfully created.
759    - found (int): The number of existing records found and potentially updated.
760    - bad (int): The number of records that failed to import due to errors.
761    """
762    created: int
763    found: int
764    bad: int

Statistics summarizing the results of an import operation.

Attributes:

  • created (int): The number of new records successfully created.
  • found (int): The number of existing records found and potentially updated.
  • bad (int): The number of records that failed to import due to errors.
ImportStatistics(created: int, found: int, bad: int)
created: int
found: int
bad: int
@dataclasses.dataclass
class CSVRecord(zakat.StrictDataclass):
767@dataclasses.dataclass
768class CSVRecord(StrictDataclass):
769    """
770    Represents a single record read from a CSV file.
771
772    Attributes:
773    - index (int): The original row number of the record in the CSV file (0-based).
774    - account (str): The account identifier.
775    - desc (str): A description associated with the record.
776    - value (int): The numerical value of the record.
777    - date (str): The date associated with the record (format may vary).
778    - rate (float): A rate or factor associated with the record.
779    - reference (str): An optional reference string.
780    - hashed (str): A hashed representation of the record's content.
781    - error (str): An error message if there was an issue processing this record.
782    """
783    index: int
784    account: str
785    desc: str
786    value: int
787    date: str
788    rate: float
789    reference: str
790    hashed: str
791    error: str

Represents a single record read from a CSV file.

Attributes:

  • index (int): The original row number of the record in the CSV file (0-based).
  • account (str): The account identifier.
  • desc (str): A description associated with the record.
  • value (int): The numerical value of the record.
  • date (str): The date associated with the record (format may vary).
  • rate (float): A rate or factor associated with the record.
  • reference (str): An optional reference string.
  • hashed (str): A hashed representation of the record's content.
  • error (str): An error message if there was an issue processing this record.
CSVRecord( index: int, account: str, desc: str, value: int, date: str, rate: float, reference: str, hashed: str, error: str)
index: int
account: str
desc: str
value: int
date: str
rate: float
reference: str
hashed: str
error: str
@dataclasses.dataclass
class ImportReport(zakat.StrictDataclass):
794@dataclasses.dataclass
795class ImportReport(StrictDataclass):
796    """
797    A report summarizing the outcome of an import operation.
798
799    Attributes:
800    - statistics (ImportStatistics): Statistical information about the import.
801    - bad (list[CSVRecord]): A list of CSV records that failed to import,
802                                 including any error messages.
803    """
804    statistics: ImportStatistics
805    bad: list[CSVRecord]

A report summarizing the outcome of an import operation.

Attributes:

  • statistics (ImportStatistics): Statistical information about the import.
  • bad (list[CSVRecord]): A list of CSV records that failed to import, including any error messages.
ImportReport( statistics: ImportStatistics, bad: list[CSVRecord])
statistics: ImportStatistics
bad: list[CSVRecord]
class ZakatTracker:
1322class ZakatTracker:
1323    """
1324    A class for tracking and calculating Zakat.
1325
1326    This class provides functionalities for recording transactions, calculating Zakat due,
1327    and managing account balances. It also offers features like importing transactions from
1328    CSV files, exporting data to JSON format, and saving/loading the tracker state.
1329
1330    The `ZakatTracker` class is designed to handle both positive and negative transactions,
1331    allowing for flexible tracking of financial activities related to Zakat. It also supports
1332    the concept of a 'Nisab' (minimum threshold for Zakat) and a 'haul' (complete one year for Transaction) can calculate Zakat due
1333    based on the current silver price.
1334
1335    The class uses a json file as its database to persist the tracker state,
1336    ensuring data integrity across sessions. It also provides options for enabling or
1337    disabling history tracking, allowing users to choose their preferred level of detail.
1338
1339    In addition, the `ZakatTracker` class includes various helper methods like
1340    `time`, `time_to_datetime`, `lock`, `free`, `recall`, `save`, `load`
1341    and more. These methods provide additional functionalities and flexibility
1342    for interacting with and managing the Zakat tracker.
1343
1344    Attributes:
1345    - ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage.
1346    - ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat.
1347    - ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price.
1348    - ZakatTracker.Version (function): The version of the ZakatTracker class.
1349
1350    Data Structure:
1351    
1352    The ZakatTracker class utilizes a nested dataclasses structure called '__vault' to store and manage data, here below is just a demonstration:
1353
1354        __vault (dict):
1355            - account (dict):
1356                - {account_id} (dict):
1357                    - balance (int): The current balance of the account.
1358                    - name (str): The name of the account.
1359                    - created (int): The creation time for the account.
1360                    - box (dict): A dictionary storing transaction details.
1361                        - {timestamp} (dict):
1362                            - capital (int): The initial amount of the transaction.
1363                            - rest (int): The remaining amount after Zakat deductions and withdrawal.
1364                            - zakat (dict):
1365                                - count (int): The number of times Zakat has been calculated for this transaction.
1366                                - last (int): The timestamp of the last Zakat calculation.
1367                                - total (int): The total Zakat deducted from this transaction.
1368                    - count (int): The total number of transactions for the account.
1369                    - log (dict): A dictionary storing transaction logs.
1370                        - {timestamp} (dict):
1371                            - value (int): The transaction amount (positive or negative).
1372                            - desc (str): The description of the transaction.
1373                            - ref (int): The box reference (positive or None).
1374                            - file (dict): A dictionary storing file references associated with the transaction.
1375                    - hide (bool): Indicates whether the account is hidden or not.
1376                    - zakatable (bool): Indicates whether the account is subject to Zakat.
1377            - exchange (dict):
1378                - {account_id} (dict):
1379                    - {timestamps} (dict):
1380                        - rate (float): Exchange rate when compared to local currency.
1381                        - description (str): The description of the exchange rate.
1382            - history (dict):
1383                - {lock_timestamp} (dict): A list of dictionaries storing the history of actions performed.
1384                    - {order_timestamp} (dict):
1385                        - {action_dict} (dict):
1386                            - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
1387                            - account (str): The account reference associated with the action.
1388                            - ref (int): The reference number of the transaction.
1389                            - file (int): The reference number of the file (if applicable).
1390                            - key (str): The key associated with the action (e.g., 'rest', 'total').
1391                            - value (int): The value associated with the action.
1392                            - math (MathOperation): The mathematical operation performed (if applicable).
1393            - lock (int or None): The timestamp indicating the current lock status (None if not locked).
1394            - report (dict):
1395                - {timestamp} (tuple): A tuple storing Zakat report details.
1396    """
1397
1398    @staticmethod
1399    def Version() -> str:
1400        """
1401        Returns the current version of the software.
1402
1403        This function returns a string representing the current version of the software,
1404        including major, minor, and patch version numbers in the format 'X.Y.Z'.
1405
1406        Returns:
1407        - str: The current version of the software.
1408        """
1409        version = '0.3.4'
1410        git_hash, unstaged_count, commit_count_since_last_tag = get_git_status()
1411        if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0):
1412            version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}"
1413            print(version)
1414        return version
1415
1416    @staticmethod
1417    def ZakatCut(x: float) -> float:
1418        """
1419        Calculates the Zakat amount due on an asset.
1420
1421        This function calculates the zakat amount due on a given asset value over one lunar year.
1422        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
1423        that exceeds a certain threshold (Nisab).
1424
1425        Parameters:
1426        - x (float): The total value of the asset on which Zakat is to be calculated.
1427
1428        Returns:
1429        - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
1430        """
1431        return 0.025 * x  # Zakat Cut in one Lunar Year
1432
1433    @staticmethod
1434    def TimeCycle(days: int = 355) -> int:
1435        """
1436        Calculates the approximate duration of a lunar year in nanoseconds.
1437
1438        This function calculates the approximate duration of a lunar year based on the given number of days.
1439        It converts the given number of days into nanoseconds for use in high-precision timing applications.
1440
1441        Parameters:
1442        - days (int, optional): The number of days in a lunar year. Defaults to 355,
1443              which is an approximation of the average length of a lunar year.
1444
1445        Returns:
1446        - int: The approximate duration of a lunar year in nanoseconds.
1447        """
1448        return int(60 * 60 * 24 * days * 1e9)  # Lunar Year in nanoseconds
1449
1450    @staticmethod
1451    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
1452        """
1453        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
1454
1455        This function calculates the Nisab value, which is the minimum threshold of wealth,
1456        that makes an individual liable for paying Zakat.
1457        The Nisab value is determined by the equivalent value of a specific amount
1458        of gold or silver (currently 595 grams in silver) in the local currency.
1459
1460        Parameters:
1461        - gram_price (float): The price per gram of Nisab.
1462        - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver.
1463
1464        Returns:
1465        - float: The total value of Nisab based on the given price per gram.
1466        """
1467        return gram_price * gram_quantity
1468
1469    @staticmethod
1470    def ext() -> str:
1471        """
1472        Returns the file extension used by the ZakatTracker class.
1473
1474        Parameters:
1475        None
1476
1477        Returns:
1478        - str: The file extension used by the ZakatTracker class, which is 'json'.
1479        """
1480        return 'json'
1481
1482    __base_path = pathlib.Path("")
1483    __vault_path = pathlib.Path("")
1484    __memory_mode = False
1485    __debug_output: list[any] = []
1486    __vault: Vault
1487
1488    def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True):
1489        """
1490        Initialize ZakatTracker with database path and history mode.
1491
1492        Parameters:
1493        - db_path (str, optional): The path to the database  directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
1494        - history_mode (bool, optional): The mode for tracking history. Default is True.
1495
1496        Returns:
1497        None
1498        """
1499        self.reset()
1500        self.__memory_mode = db_path == ':memory:'
1501        self.__history(history_mode)
1502        if not self.__memory_mode:
1503            self.path(f'{db_path}/db.{self.ext()}')
1504
1505    def memory_mode(self) -> bool:
1506        """
1507        Check if the ZakatTracker is operating in memory mode.
1508
1509        Returns:
1510        - bool: True if the database is in memory, False otherwise.
1511        """
1512        return self.__memory_mode
1513
1514    def path(self, path: Optional[str] = None) -> str:
1515        """
1516        Set or get the path to the database file.
1517
1518        If no path is provided, the current path is returned.
1519        If a path is provided, it is set as the new path.
1520        The function also creates the necessary directories if the provided path is a file.
1521
1522        Parameters:
1523        - path (str, optional): The new path to the database file. If not provided, the current path is returned.
1524
1525        Returns:
1526        - str: The current or new path to the database file.
1527        """
1528        if path is None:
1529            return str(self.__vault_path)
1530        self.__vault_path = pathlib.Path(path).resolve()
1531        base_path = pathlib.Path(path).resolve()
1532        if base_path.is_file() or base_path.suffix:
1533            base_path = base_path.parent
1534        base_path.mkdir(parents=True, exist_ok=True)
1535        self.__base_path = base_path
1536        return str(self.__vault_path)
1537
1538    def base_path(self, *args) -> str:
1539        """
1540        Generate a base path by joining the provided arguments with the existing base path.
1541
1542        Parameters:
1543        - *args (str): Variable length argument list of strings to be joined with the base path.
1544
1545        Returns:
1546        - str: The generated base path. If no arguments are provided, the existing base path is returned.
1547        """
1548        if not args:
1549            return str(self.__base_path)
1550        filtered_args = []
1551        ignored_filename = None
1552        for arg in args:
1553            if pathlib.Path(arg).suffix:
1554                ignored_filename = arg
1555            else:
1556                filtered_args.append(arg)
1557        base_path = pathlib.Path(self.__base_path)
1558        full_path = base_path.joinpath(*filtered_args)
1559        full_path.mkdir(parents=True, exist_ok=True)
1560        if ignored_filename is not None:
1561            return full_path.resolve() / ignored_filename  # Join with the ignored filename
1562        return str(full_path.resolve())
1563
1564    @staticmethod
1565    def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
1566        """
1567        Scales a numerical value by a specified power of 10, returning an integer.
1568
1569        This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and
1570        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
1571
1572        Parameters:
1573        - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
1574        - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
1575            by a factor of 100 (e.g., converts 1.23 to 123).
1576
1577        Returns:
1578        - The scaled value, rounded to the nearest integer.
1579
1580        Raises:
1581        - TypeError: If the input `x` is not a valid numeric type.
1582
1583        Examples:
1584        ```bash
1585        >>> ZakatTracker.scale(3.14159)
1586        314
1587        >>> ZakatTracker.scale(1234, decimal_places=3)
1588        1234000
1589        >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4)
1590        50
1591        ```
1592        """
1593        if not isinstance(x, (float, int, decimal.Decimal)):
1594            raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.')
1595        return int(decimal.Decimal(f'{x:.{decimal_places}f}') * (10 ** decimal_places))
1596
1597    @staticmethod
1598    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal:
1599        """
1600        Unscales an integer by a power of 10.
1601
1602        Parameters:
1603        - x (int): The integer to unscale.
1604        - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
1605        - decimal_places (int, optional): The power of 10 to use. Defaults to 2.
1606
1607        Returns:
1608        - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.
1609
1610        Raises:
1611        - TypeError: If the return_type is not float or decimal.Decimal.
1612        """
1613        if return_type not in (float, decimal.Decimal):
1614            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.')
1615        return round(return_type(x / (10 ** decimal_places)), decimal_places)
1616
1617    def reset(self) -> None:
1618        """
1619        Reset the internal data structure to its initial state.
1620
1621        Parameters:
1622        None
1623
1624        Returns:
1625        None
1626        """
1627        self.__vault = Vault()
1628
1629    def clean_history(self, lock: Optional[Timestamp] = None) -> int:
1630        """
1631        Cleans up the empty history records of actions performed on the ZakatTracker instance.
1632
1633        Parameters:
1634        - lock (Timestamp, optional): The lock ID is used to clean up the empty history.
1635            If not provided, it cleans up the empty history records for all locks.
1636
1637        Returns:
1638        - int: The number of locks cleaned up.
1639        """
1640        count = 0
1641        if lock in self.__vault.history:
1642            if len(self.__vault.history[lock]) <= 0:
1643                count += 1
1644                del self.__vault.history[lock]
1645            return count
1646        for key in self.__vault.history:
1647            if len(self.__vault.history[key]) <= 0:
1648                count += 1
1649                del self.__vault.history[key]
1650        return count
1651
1652    def __history(self, status: Optional[bool] = None) -> bool:
1653        """
1654        Enable or disable history tracking.
1655
1656        Parameters:
1657        - status (bool, optional): The status of history tracking. Default is True.
1658
1659        Returns:
1660        None
1661        """
1662        if status is not None:
1663            self.__history_mode = status
1664        return self.__history_mode
1665
1666    def __step(self, action: Optional[Action] = None,
1667                    account: Optional[AccountID] = None,
1668                    ref: Optional[Timestamp] = None,
1669                    file: Optional[Timestamp] = None,
1670                    value: Optional[any] = None, # !!!
1671                    key: Optional[str] = None,
1672                    math_operation: Optional[MathOperation] = None,
1673                    lock_once: bool = True,
1674                    debug: bool = False,
1675                ) -> Optional[Timestamp]:
1676        """
1677        This method is responsible for recording the actions performed on the ZakatTracker.
1678
1679        Parameters:
1680        - action (Action, optional): The type of action performed.
1681        - account (AccountID, optional): The account reference on which the action was performed.
1682        - ref (Optional, optional): The reference number of the action.
1683        - file (Timestamp, optional): The file reference number of the action.
1684        - value (any, optional): The value associated with the action.
1685        - key (str, optional): The key associated with the action.
1686        - math_operation (MathOperation, optional): The mathematical operation performed during the action.
1687        - lock_once (bool, optional): Indicates whether a lock should be acquired only once. Defaults to True.
1688        - debug (bool, optional): If True, the function will print debug information. Default is False.
1689
1690        Returns:
1691        - Optional[Timestamp]: The lock time of the recorded action. If no lock was performed, it returns 0.
1692        """
1693        if not self.__history():
1694            return None
1695        no_lock = self.nolock()
1696        lock = self.__vault.lock
1697        if no_lock:
1698            lock = self.__vault.lock = Time.time()
1699            self.__vault.history[lock] = {}
1700        if action is None:
1701            if lock_once:
1702                assert no_lock, 'forbidden: lock called twice!!!'
1703            return lock
1704        if debug:
1705             print_stack()
1706        assert lock is not None
1707        assert lock > 0
1708        assert account is None or action != Action.REPORT
1709        self.__vault.history[lock][Time.time()] = History(
1710            action=action,
1711            account=account,
1712            ref=ref,
1713            file=file,
1714            key=key,
1715            value=value,
1716            math=math_operation,
1717        )
1718        return lock
1719
1720    def nolock(self) -> bool:
1721        """
1722        Check if the vault lock is currently not set.
1723
1724        Parameters:
1725        None
1726
1727        Returns:
1728        - bool: True if the vault lock is not set, False otherwise.
1729        """
1730        return self.__vault.lock is None
1731
1732    def __lock(self) -> Optional[Timestamp]:
1733        """
1734        Acquires a lock, potentially repeatedly, by calling the internal `_step` method.
1735
1736        This method specifically invokes the `_step` method with `lock_once` set to `False`
1737        indicating that the lock should be acquired even if it was previously acquired.
1738        This is useful for ensuring a lock is held throughout a critical section of code
1739
1740        Returns:
1741        - Optional[Timestamp]: The status code or result returned by the `_step` method, indicating theoutcome of the lock acquisition attempt.
1742        """
1743        return self.__step(lock_once=False)
1744
1745    def lock(self) -> Optional[Timestamp]:
1746        """
1747        Acquires a lock on the ZakatTracker instance.
1748
1749        Parameters:
1750        None
1751
1752        Returns:
1753        - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later.
1754        """
1755        return self.__step()
1756
1757    def steps(self) -> dict:
1758        """
1759        Returns a copy of the history of steps taken in the ZakatTracker.
1760
1761        The history is a dictionary where each key is a unique identifier for a step,
1762        and the corresponding value is a dictionary containing information about the step.
1763
1764        Parameters:
1765        None
1766
1767        Returns:
1768        - dict: A copy of the history of steps taken in the ZakatTracker.
1769        """
1770        return {
1771            lock: {
1772                timestamp: dataclasses.asdict(history)
1773                for timestamp, history in steps.items()
1774            }
1775            for lock, steps in self.__vault.history.items()
1776        }
1777
1778    def free(self, lock: Timestamp, auto_save: bool = True) -> bool:
1779        """
1780        Releases the lock on the database.
1781
1782        Parameters:
1783        - lock (Timestamp): The lock ID to be released.
1784        - auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
1785
1786        Returns:
1787        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
1788        """
1789        if lock == self.__vault.lock:
1790            self.clean_history(lock)
1791            self.__vault.lock = None
1792            if auto_save and not self.memory_mode():
1793                return self.save(self.path())
1794            return True
1795        return False
1796
1797    def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool:
1798        """
1799        Revert the last operation.
1800
1801        Parameters:
1802        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
1803        - lock (Timestamp, optional): An optional lock value to ensure the recall
1804                operation is performed on the expected history entry. If provided,
1805                it checks if the current lock and the most recent history key
1806                match the given lock value. Defaults to None.
1807        - debug (bool, optional): If True, the function will print debug information. Default is False.
1808
1809        Returns:
1810        - bool: True if the operation was successful, False otherwise.
1811        """
1812        if not self.nolock() or len(self.__vault.history) == 0:
1813            return False
1814        if len(self.__vault.history) <= 0:
1815            return False
1816        ref = sorted(self.__vault.history.keys())[-1]
1817        if debug:
1818            print('recall', ref)
1819        memory = sorted(self.__vault.history[ref], reverse=True)
1820        if debug:
1821            print(type(memory), 'memory', memory)
1822        if lock is not None:
1823            assert self.__vault.lock == lock, "Invalid current lock"
1824            assert ref == lock, "Invalid last lock"
1825            assert self.__history(), "History mode should be enabled, found off!!!"
1826        sub_positive_log_negative = 0
1827        for i in memory:
1828            x = self.__vault.history[ref][i]
1829            if debug:
1830                print(type(x), x)
1831            if x.action != Action.REPORT:
1832                assert x.account is not None
1833                if x.action != Action.EXCHANGE:
1834                    assert self.account_exists(x.account)
1835            match x.action:
1836                case Action.CREATE:
1837                    if debug:
1838                        print('account', self.__vault.account[x.account])
1839                    assert len(self.__vault.account[x.account].box) == 0
1840                    assert len(self.__vault.account[x.account].log) == 0
1841                    assert self.__vault.account[x.account].balance == 0
1842                    assert self.__vault.account[x.account].count == 0
1843                    assert self.__vault.account[x.account].name == ''
1844                    if dry:
1845                        continue
1846                    del self.__vault.account[x.account]
1847
1848                case Action.NAME:
1849                    assert x.value is not None
1850                    if dry:
1851                        continue
1852                    self.__vault.account[x.account].name = x.value
1853
1854                case Action.TRACK:
1855                    assert x.value is not None
1856                    assert x.ref is not None
1857                    if dry:
1858                        continue
1859                    self.__vault.account[x.account].balance -= x.value
1860                    self.__vault.account[x.account].count -= 1
1861                    del self.__vault.account[x.account].box[x.ref]
1862
1863                case Action.LOG:
1864                    assert x.ref in self.__vault.account[x.account].log
1865                    assert x.value is not None
1866                    if dry:
1867                        continue
1868                    if sub_positive_log_negative == -x.value:
1869                        self.__vault.account[x.account].count -= 1
1870                        sub_positive_log_negative = 0
1871                    box_ref = self.__vault.account[x.account].log[x.ref].ref
1872                    if not box_ref is None:
1873                        assert self.box_exists(x.account, box_ref)
1874                        box_value = self.__vault.account[x.account].log[x.ref].value
1875                        assert box_value < 0
1876
1877                        try:
1878                            self.__vault.account[x.account].box[box_ref].rest += -box_value
1879                        except TypeError:
1880                            self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value)
1881
1882                        try:
1883                            self.__vault.account[x.account].balance += -box_value
1884                        except TypeError:
1885                            self.__vault.account[x.account].balance += decimal.Decimal(-box_value)
1886
1887                        self.__vault.account[x.account].count -= 1
1888                    del self.__vault.account[x.account].log[x.ref]
1889
1890                case Action.SUBTRACT:
1891                    assert x.ref in self.__vault.account[x.account].box
1892                    assert x.value is not None
1893                    if dry:
1894                        continue
1895                    self.__vault.account[x.account].box[x.ref].rest += x.value
1896                    self.__vault.account[x.account].balance += x.value
1897                    sub_positive_log_negative = x.value
1898
1899                case Action.ADD_FILE:
1900                    assert x.ref in self.__vault.account[x.account].log
1901                    assert x.file is not None
1902                    assert dry or x.file in self.__vault.account[x.account].log[x.ref].file
1903                    if dry:
1904                        continue
1905                    del self.__vault.account[x.account].log[x.ref].file[x.file]
1906
1907                case Action.REMOVE_FILE:
1908                    assert x.ref in self.__vault.account[x.account].log
1909                    assert x.file is not None
1910                    assert x.value is not None
1911                    if dry:
1912                        continue
1913                    self.__vault.account[x.account].log[x.ref].file[x.file] = x.value
1914
1915                case Action.BOX_TRANSFER:
1916                    assert x.ref in self.__vault.account[x.account].box
1917                    assert x.value is not None
1918                    if dry:
1919                        continue
1920                    self.__vault.account[x.account].box[x.ref].rest -= x.value
1921
1922                case Action.EXCHANGE:
1923                    assert x.account in self.__vault.exchange
1924                    assert x.ref in self.__vault.exchange[x.account]
1925                    if dry:
1926                        continue
1927                    del self.__vault.exchange[x.account][x.ref]
1928
1929                case Action.REPORT:
1930                    assert x.ref in self.__vault.report
1931                    if dry:
1932                        continue
1933                    del self.__vault.report[x.ref]
1934
1935                case Action.ZAKAT:
1936                    assert x.ref in self.__vault.account[x.account].box
1937                    assert x.key is not None
1938                    assert hasattr(self.__vault.account[x.account].box[x.ref].zakat, x.key)
1939                    if dry:
1940                        continue
1941                    match x.math:
1942                        case MathOperation.ADDITION:
1943                            setattr(
1944                                self.__vault.account[x.account].box[x.ref].zakat,
1945                                x.key,
1946                                getattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) - x.value,
1947                            )
1948                        case MathOperation.EQUAL:
1949                            setattr(
1950                                self.__vault.account[x.account].box[x.ref].zakat,
1951                                x.key,
1952                                x.value,
1953                            )
1954                        case MathOperation.SUBTRACTION:
1955                            setattr(
1956                                self.__vault.account[x.account].box[x.ref],
1957                                x.key,
1958                                getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value,
1959                            )
1960
1961        if not dry:
1962            del self.__vault.history[ref]
1963        return True
1964
1965    def vault(self) -> dict:
1966        """
1967        Returns a copy of the internal vault dictionary.
1968
1969        This method is used to retrieve the current state of the ZakatTracker object.
1970        It provides a snapshot of the internal data structure, allowing for further
1971        processing or analysis.
1972
1973        Parameters:
1974        None
1975
1976        Returns:
1977        - dict: A copy of the internal vault dictionary.
1978        """
1979        return dataclasses.asdict(self.__vault)
1980
1981    @staticmethod
1982    def stats_init() -> FileStats:
1983        """
1984        Initialize and return the initial file statistics.
1985
1986        Returns:
1987        - FileStats: A :class:`FileStats` instance with initial values
1988            of 0 bytes for both RAM and database.
1989        """
1990        return FileStats(
1991            database=SizeInfo(0, '0'),
1992            ram=SizeInfo(0, '0'),
1993        )
1994
1995    def stats(self, ignore_ram: bool = True) -> FileStats:
1996        """
1997        Calculates and returns statistics about the object's data storage.
1998
1999        This method determines the size of the database file on disk and the
2000        size of the data currently held in RAM (likely within a dictionary).
2001        Both sizes are reported in bytes and in a human-readable format
2002        (e.g., KB, MB).
2003
2004        Parameters:
2005        - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
2006
2007        Returns:
2008        - FileStats: A dataclass containing the following statistics:
2009
2010            * 'database': A tuple with two elements:
2011                - The database file size in bytes (float).
2012                - The database file size in human-readable format (str).
2013            * 'ram': A tuple with two elements:
2014                - The RAM usage (dictionary size) in bytes (float).
2015                - The RAM usage in human-readable format (str).
2016
2017        Example:
2018        ```bash
2019        >>> x = ZakatTracker()
2020        >>> stats = x.stats()
2021        >>> print(stats.database)
2022        SizeInfo(bytes=256000, human_readable='250.0 KB')
2023        >>> print(stats.ram)
2024        SizeInfo(bytes=12345, human_readable='12.1 KB')
2025        ```
2026        """
2027        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
2028        file_size = os.path.getsize(self.path())
2029        return FileStats(
2030            database=SizeInfo(file_size, self.human_readable_size(file_size)),
2031            ram=SizeInfo(ram_size, self.human_readable_size(ram_size)),
2032        )
2033
2034    def files(self) -> list[FileInfo]:
2035        """
2036        Retrieves information about files associated with this class.
2037
2038        This class method provides a standardized way to gather details about
2039        files used by the class for storage, snapshots, and CSV imports.
2040
2041        Parameters:
2042        None
2043
2044        Returns:
2045        - list[FileInfo]: A list of dataclass, each containing information
2046            about a specific file:
2047
2048            * type (str): The type of file ('database', 'snapshot', 'import_csv').
2049            * path (str): The full file path.
2050            * exists (bool): Whether the file exists on the filesystem.
2051            * size (int): The file size in bytes (0 if the file doesn't exist).
2052            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
2053        """
2054        result = []
2055        for file_type, path in {
2056            'database': self.path(),
2057            'snapshot': self.snapshot_cache_path(),
2058            'import_csv': self.import_csv_cache_path(),
2059        }.items():
2060            exists = os.path.exists(path)
2061            size = os.path.getsize(path) if exists else 0
2062            human_readable_size = self.human_readable_size(size) if exists else '0'
2063            result.append(FileInfo(
2064                type=file_type,
2065                path=path,
2066                exists=exists,
2067                size=size,
2068                human_readable_size=human_readable_size,
2069            ))
2070        return result
2071
2072    def account_exists(self, account: AccountID) -> bool:
2073        """
2074        Check if the given account exists in the vault.
2075
2076        Parameters:
2077        - account (AccountID): The account reference to check.
2078
2079        Returns:
2080        - bool: True if the account exists, False otherwise.
2081        """
2082        account = AccountID(account)
2083        return account in self.__vault.account
2084
2085    def box_size(self, account: AccountID) -> int:
2086        """
2087        Calculate the size of the box for a specific account.
2088
2089        Parameters:
2090        - account (AccountID): The account reference for which the box size needs to be calculated.
2091
2092        Returns:
2093        - int: The size of the box for the given account. If the account does not exist, -1 is returned.
2094        """
2095        if self.account_exists(account):
2096            return len(self.__vault.account[account].box)
2097        return -1
2098
2099    def log_size(self, account: AccountID) -> int:
2100        """
2101        Get the size of the log for a specific account.
2102
2103        Parameters:
2104        - account (AccountID): The account reference for which the log size needs to be calculated.
2105
2106        Returns:
2107        - int: The size of the log for the given account. If the account does not exist, -1 is returned.
2108        """
2109        if self.account_exists(account):
2110            return len(self.__vault.account[account].log)
2111        return -1
2112
2113    @staticmethod
2114    def hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
2115        """
2116        Calculates the hash of given byte data using the specified algorithm.
2117
2118        Parameters:
2119        - data (bytes): The byte data to hash.
2120        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2121
2122        Returns:
2123        - str: The hexadecimal representation of the data's hash.
2124        """
2125        hash_obj = hashlib.new(algorithm)
2126        hash_obj.update(data)
2127        return hash_obj.hexdigest()
2128
2129    @staticmethod
2130    def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
2131        """
2132        Calculates the hash of a file using the specified algorithm.
2133
2134        Parameters:
2135        - file_path (str): The path to the file.
2136        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2137
2138        Returns:
2139        - str: The hexadecimal representation of the file's hash.
2140        """
2141        hash_obj = hashlib.new(algorithm)  # Create the hash object
2142        with open(file_path, 'rb') as file:  # Open file in binary mode for reading
2143            for chunk in iter(lambda: file.read(4096), b''):  # Read file in chunks
2144                hash_obj.update(chunk)
2145        return hash_obj.hexdigest()  # Return the hash as a hexadecimal string
2146
2147    def snapshot_cache_path(self):
2148        """
2149        Generate the path for the cache file used to store snapshots.
2150
2151        The cache file is a json file that stores the timestamps of the snapshots.
2152        The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'.
2153
2154        Parameters:
2155        None
2156
2157        Returns:
2158        - str: The path to the cache file.
2159        """
2160        path = str(self.path())
2161        ext = self.ext()
2162        ext_len = len(ext)
2163        if path.endswith(f'.{ext}'):
2164            path = path[:-ext_len - 1]
2165        _, filename = os.path.split(path + f'.snapshots.{ext}')
2166        return self.base_path(filename)
2167
2168    def snapshot(self) -> bool:
2169        """
2170        This function creates a snapshot of the current database state.
2171
2172        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
2173        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
2174        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
2175        in a new json 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.
2176
2177        Parameters:
2178        None
2179
2180        Returns:
2181        - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
2182        """
2183        current_hash = self.hash_file(self.path())
2184        cache: dict[str, int] = {}  # hash: time_ns
2185        try:
2186            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2187                cache = json.load(stream, cls=JSONDecoder)
2188        except:
2189            pass
2190        if current_hash in cache:
2191            return True
2192        ref = time.time_ns()
2193        cache[current_hash] = ref
2194        if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')):
2195            return False
2196        with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream:
2197            stream.write(json.dumps(cache, cls=JSONEncoder))
2198        return True
2199
2200    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
2201            -> dict[int, tuple[str, str, bool]]:
2202        """
2203        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
2204
2205        Parameters:
2206        - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
2207        - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.
2208
2209        Returns:
2210        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
2211        and the values are tuples containing the snapshot's hash, path, and existence status.
2212        """
2213        cache: dict[str, int] = {}  # hash: time_ns
2214        try:
2215            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2216                cache = json.load(stream, cls=JSONDecoder)
2217        except:
2218            pass
2219        if not cache:
2220            return {}
2221        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
2222        for hash_file, ref in cache.items():
2223            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
2224            exists = os.path.exists(path)
2225            valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True
2226            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
2227                continue
2228            if exists or not hide_missing:
2229                result[ref] = (hash_file, path, exists)
2230        return result
2231
2232    def ref_exists(self, account: AccountID, ref_type: str, ref: Timestamp) -> bool:
2233        """
2234        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
2235
2236        Parameters:
2237        - account (AccountID): The account reference for which to check the existence of the reference.
2238        - ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
2239        - ref (Timestamp): The reference (transaction) number to check for existence.
2240
2241        Returns:
2242        - bool: True if the reference exists for the given account and reference type, False otherwise.
2243        """
2244        account = AccountID(account)
2245        if account in self.__vault.account:
2246            return ref in getattr(self.__vault.account[account], ref_type)
2247        return False
2248
2249    def box_exists(self, account: AccountID, ref: Timestamp) -> bool:
2250        """
2251        Check if a specific box (transaction) exists in the vault for a given account and reference.
2252
2253        Parameters:
2254        - account (AccountID): The account reference for which to check the existence of the box.
2255        - ref (Timestamp): The reference (transaction) number to check for existence.
2256
2257        Returns:
2258        - bool: True if the box exists for the given account and reference, False otherwise.
2259        """
2260        return self.ref_exists(account, 'box', ref)
2261
2262    def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'),
2263              created_time_ns: Optional[Timestamp] = None,
2264              debug: bool = False) -> Optional[Timestamp]:
2265        """
2266        This function tracks a transaction for a specific account, so it do creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
2267
2268        Parameters:
2269        - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
2270        - desc (str, optional): The description of the transaction. Default is an empty string.
2271        - account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'.
2272        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None.
2273        - debug (bool, optional): Whether to print debug information. Default is False.
2274
2275        Returns:
2276        - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD).
2277
2278        Raises:
2279        - ValueError: The created_time_ns should be greater than zero.
2280        - ValueError: The log transaction happened again in the same nanosecond time.
2281        - ValueError: The box transaction happened again in the same nanosecond time.
2282        """
2283        return self.__track(
2284            unscaled_value=unscaled_value,
2285            desc=desc,
2286            account=account,
2287            logging=True,
2288            created_time_ns=created_time_ns,
2289            debug=debug,
2290        )
2291
2292    def __track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'),
2293              logging: bool = True,
2294              created_time_ns: Optional[Timestamp] = None,
2295              debug: bool = False) -> Optional[Timestamp]:
2296        """
2297        Internal function to track a transaction.
2298
2299        This function handles the core logic for tracking a transaction, including account creation, logging, and box creation.
2300
2301        Parameters:
2302        - unscaled_value (float | int | decimal.Decimal, optional): The monetary value of the transaction. Defaults to 0.
2303        - desc (str, optional): A description of the transaction. Defaults to an empty string.
2304        - account (AccountID, optional): The reference of the account to track the transaction for. Defaults to '1'.
2305        - logging (bool, optional): Enables transaction logging. Defaults to True.
2306        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since the epoch. If not provided, the current time is used. Defaults to None.
2307        - debug (bool, optional): Enables debug printing. Defaults to False.
2308
2309        Returns:
2310        - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since the epoch.
2311
2312        Raises:
2313        - ValueError: If `created_time_ns` is not greater than zero.
2314        - ValueError: If a box transaction already exists for the given `account` and `created_time_ns`.
2315        """
2316        if debug:
2317            print('track', f'unscaled_value={unscaled_value}, debug={debug}')
2318        account = AccountID(account)
2319        if created_time_ns is None:
2320            created_time_ns = Time.time()
2321        if created_time_ns <= 0:
2322            raise ValueError('The created should be greater than zero.')
2323        no_lock = self.nolock()
2324        lock = self.__lock()
2325        if not self.account_exists(account):
2326            if debug:
2327                print(f'account {account} created')
2328            self.__vault.account[account] = Account(
2329                balance=0,
2330                created=created_time_ns,
2331            )
2332            self.__step(Action.CREATE, account)
2333        if unscaled_value == 0:
2334            if no_lock:
2335                assert lock is not None
2336                self.free(lock)
2337            return None
2338        value = self.scale(unscaled_value)
2339        if logging:
2340            self.__log(value=value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
2341        if debug:
2342            print('create-box', created_time_ns)
2343        if self.box_exists(account, created_time_ns):
2344            raise ValueError(f'The box transaction happened again in the same nanosecond time({created_time_ns}).')
2345        if debug:
2346            print('created-box', created_time_ns)
2347        self.__vault.account[account].box[created_time_ns] = Box(
2348            capital=value,
2349            rest=value,
2350            zakat=BoxZakat(0, 0, 0),
2351        )
2352        self.__step(Action.TRACK, account, ref=created_time_ns, value=value)
2353        if no_lock:
2354            assert lock is not None
2355            self.free(lock)
2356        return created_time_ns
2357
2358    def log_exists(self, account: AccountID, ref: Timestamp) -> bool:
2359        """
2360        Checks if a specific transaction log entry exists for a given account.
2361
2362        Parameters:
2363        - account (AccountID): The account reference associated with the transaction log.
2364        - ref (Timestamp): The reference to the transaction log entry.
2365
2366        Returns:
2367        - bool: True if the transaction log entry exists, False otherwise.
2368        """
2369        return self.ref_exists(account, 'log', ref)
2370
2371    def __log(self, value: int, desc: str = '', account: AccountID = AccountID('1'),
2372             created_time_ns: Optional[Timestamp] = None,
2373             ref: Optional[Timestamp] = None,
2374             debug: bool = False) -> Timestamp:
2375        """
2376        Log a transaction into the account's log by updates the account's balance, count, and log with the transaction details.
2377        It also creates a step in the history of the transaction.
2378
2379        Parameters:
2380        - value (int): The value of the transaction.
2381        - desc (str, optional): The description of the transaction.
2382        - account (AccountID, optional): The account reference to log the transaction into. Default is '1'.
2383        - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
2384                                           If not provided, it will be generated.
2385        - ref (Timestamp, optional): The reference of the object.
2386        - debug (bool, optional): Whether to print debug information. Default is False.
2387
2388        Returns:
2389        - Timestamp: The timestamp of the logged transaction.
2390
2391        Raises:
2392        - ValueError: The created_time_ns should be greater than zero.
2393        - ValueError: The log transaction happened again in the same nanosecond time.
2394        """
2395        if debug:
2396            print('_log', f'debug={debug}')
2397        account = AccountID(account)
2398        if created_time_ns is None:
2399            created_time_ns = Time.time()
2400        if created_time_ns <= 0:
2401            raise ValueError('The created should be greater than zero.')
2402        try:
2403            self.__vault.account[account].balance += value
2404        except TypeError:
2405            self.__vault.account[account].balance += decimal.Decimal(value)
2406        self.__vault.account[account].count += 1
2407        if debug:
2408            print('create-log', created_time_ns)
2409        if self.log_exists(account, created_time_ns):
2410            raise ValueError(f'The log transaction happened again in the same nanosecond time({created_time_ns}).')
2411        if debug:
2412            print('created-log', created_time_ns)
2413        self.__vault.account[account].log[created_time_ns] = Log(
2414            value=value,
2415            desc=desc,
2416            ref=ref,
2417            file={},
2418        )
2419        self.__step(Action.LOG, account, ref=created_time_ns, value=value)
2420        return created_time_ns
2421
2422    def exchange(self, account: AccountID, created_time_ns: Optional[Timestamp] = None,
2423                 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
2424        """
2425        This method is used to record or retrieve exchange rates for a specific account.
2426
2427        Parameters:
2428        - account (AccountID): The account reference for which the exchange rate is being recorded or retrieved.
2429        - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
2430        - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
2431        - description (str, optional): A description of the exchange rate.
2432        - debug (bool, optional): Whether to print debug information. Default is False.
2433
2434        Returns:
2435        - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
2436        it returns a dictionary with default values for the rate and description.
2437
2438        Raises:
2439        - ValueError: The created should be greater than zero.
2440        """
2441        if debug:
2442            print('exchange', f'debug={debug}')
2443        account = AccountID(account)
2444        if created_time_ns is None:
2445            created_time_ns = Time.time()
2446        if created_time_ns <= 0:
2447            raise ValueError('The created should be greater than zero.')
2448        if rate is not None:
2449            if rate <= 0:
2450                return Exchange()
2451            if account not in self.__vault.exchange:
2452                self.__vault.exchange[account] = {}
2453            if len(self.__vault.exchange[account]) == 0 and rate <= 1:
2454                return Exchange(time=created_time_ns, rate=1)
2455            no_lock = self.nolock()
2456            lock = self.__lock()
2457            self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description)
2458            self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate)
2459            if no_lock:
2460                assert lock is not None
2461                self.free(lock)
2462            if debug:
2463                print('exchange-created-1',
2464                      f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2465
2466        if account in self.__vault.exchange:
2467            valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns]
2468            if valid_rates:
2469                latest_rate = max(valid_rates, key=lambda x: x[0])
2470                if debug:
2471                    print('exchange-read-1',
2472                          f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}',
2473                          'latest_rate', latest_rate)
2474                result = latest_rate[1]
2475                result.time = latest_rate[0]
2476                return result  # إرجاع قاموس يحتوي على المعدل والوصف
2477        if debug:
2478            print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2479        return Exchange(time=created_time_ns, rate=1, description=None)  # إرجاع القيمة الافتراضية مع وصف فارغ
2480
2481    @staticmethod
2482    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
2483        """
2484        This function calculates the exchanged amount of a currency.
2485
2486        Parameters:
2487        - x (float): The original amount of the currency.
2488        - x_rate (float): The exchange rate of the original currency.
2489        - y_rate (float): The exchange rate of the target currency.
2490
2491        Returns:
2492        - float: The exchanged amount of the target currency.
2493        """
2494        return (x * x_rate) / y_rate
2495
2496    def exchanges(self) -> dict[AccountID, dict[Timestamp, Exchange]]:
2497        """
2498        Retrieve the recorded exchange rates for all accounts.
2499
2500        Parameters:
2501        None
2502
2503        Returns:
2504        - dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates.
2505        The keys are account references or numbers, and the values are dictionaries containing the exchange rates.
2506        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
2507        """
2508        return self.__vault.exchange.copy()
2509
2510    def accounts(self) -> dict[AccountID, AccountDetails]:
2511        """
2512        Returns a dictionary containing account references as keys and their respective account details as values.
2513
2514        Parameters:
2515        None
2516
2517        Returns:
2518        - dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details.
2519        """
2520        return {
2521            account_id: AccountDetails(
2522                account_id=account_id,
2523                account_name=self.__vault.account[account_id].name,
2524                balance=self.__vault.account[account_id].balance,
2525            )
2526            for account_id in self.__vault.account
2527        }
2528
2529    def boxes(self, account: AccountID) -> dict[Timestamp, Box]:
2530        """
2531        Retrieve the boxes (transactions) associated with a specific account.
2532
2533        Parameters:
2534        - account (AccountID): The account reference for which to retrieve the boxes.
2535
2536        Returns:
2537        - dict[Timestamp, Box]: A dictionary containing the boxes associated with the given account.
2538        If the account does not exist, an empty dictionary is returned.
2539        """
2540        if self.account_exists(account):
2541            return self.__vault.account[account].box
2542        return {}
2543
2544    def logs(self, account: AccountID) -> dict[Timestamp, Log]:
2545        """
2546        Retrieve the logs (transactions) associated with a specific account.
2547
2548        Parameters:
2549        - account (AccountID): The account reference for which to retrieve the logs.
2550
2551        Returns:
2552        - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account.
2553        If the account does not exist, an empty dictionary is returned.
2554        """
2555        if self.account_exists(account):
2556            return self.__vault.account[account].log
2557        return {}
2558
2559    def timeline(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False) -> Timeline:
2560        """
2561        Aggregates transaction logs into a structured timeline.
2562
2563        This method retrieves transaction logs from all accounts and organizes them
2564        into daily, weekly, monthly, and yearly summaries. Each level of the
2565        timeline includes a `TimeSummary` object with the total positive, negative,
2566        and overall values for that period. The daily level also includes a list
2567        of individual `Transaction` records.
2568
2569        Parameters:
2570        - weekday (WeekDay, optional): The day of the week to use as the anchor
2571                for weekly summaries. Defaults to WeekDay.FRIDAY.
2572        - debug (bool, optional): If True, prints intermediate debug information
2573                during processing. Defaults to False.
2574
2575        Returns:
2576        - Timeline: An object containing the aggregated transaction data, organized
2577                into daily, weekly, monthly, and yearly summaries. The 'daily'
2578                attribute is a dictionary where keys are dates (YYYY-MM-DD) and
2579                values are `DailyRecords` objects. The 'weekly' attribute is a
2580                dictionary where keys are the starting datetime of the week and
2581                values are `TimeSummary` objects. The 'monthly' attribute is a
2582                dictionary where keys are year-month strings (YYYY-MM) and values
2583                are `TimeSummary` objects. The 'yearly' attribute is a dictionary
2584                where keys are years (YYYY) and values are `TimeSummary` objects.
2585
2586        Example:
2587        ```bash
2588        >>> from zakat import tracker
2589        >>> ledger = tracker(':memory:')
2590        >>> account1_id = ledger.create_account('account1')
2591        >>> account2_id = ledger.create_account('account2')
2592        >>> ledger.subtract(51, 'desc', account1_id)
2593        >>> ref = ledger.track(100, 'desc', account2_id)
2594        >>> ledger.add_file(account2_id, ref, 'file_0')
2595        >>> ledger.add_file(account2_id, ref, 'file_1')
2596        >>> ledger.add_file(account2_id, ref, 'file_2')
2597        >>> ledger.timeline()
2598        Timeline(
2599            daily={
2600                "2025-04-06": DailyRecords(
2601                    positive=10000,
2602                    negative=5100,
2603                    total=4900,
2604                    rows=[
2605                        Transaction(
2606                            account="account2",
2607                            account_id="63879638114290122752",
2608                            desc="desc2",
2609                            file={
2610                                63879638220705865728: "file_0",
2611                                63879638223391350784: "file_1",
2612                                63879638225766047744: "file_2",
2613                            },
2614                            value=10000,
2615                            time=63879638181936513024,
2616                            transfer=False,
2617                        ),
2618                        Transaction(
2619                            account="account1",
2620                            account_id="63879638104007106560",
2621                            desc="desc",
2622                            file={},
2623                            value=-5100,
2624                            time=63879638149199421440,
2625                            transfer=False,
2626                        ),
2627                    ],
2628                )
2629            },
2630            weekly={
2631                datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary(
2632                    positive=10000, negative=0, total=10000
2633                ),
2634                datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary(
2635                    positive=0, negative=5100, total=-5100
2636                ),
2637            },
2638            monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)},
2639            yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)},
2640        )
2641        ```
2642        """
2643        logs: dict[Timestamp, list[Transaction]] = {}
2644        for account_id in self.accounts():
2645            for log_ref, log in self.logs(account_id).items():
2646                if log_ref not in logs:
2647                    logs[log_ref] = []
2648                logs[log_ref].append(Transaction(
2649                    account=self.name(account_id),
2650                    account_id=account_id,
2651                    desc=log.desc,
2652                    file=log.file,
2653                    value=log.value,
2654                    time=log_ref,
2655                    transfer=False,
2656                ))
2657        if debug:
2658            print('logs', logs)
2659        y = Timeline()
2660        for i in sorted(logs, reverse=True):
2661            dt = Time.time_to_datetime(i)
2662            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
2663            weekly = dt - datetime.timedelta(days=weekday.value)
2664            monthly = f'{dt.year}-{dt.month:02d}'
2665            yearly = dt.year
2666            # daily
2667            if daily not in y.daily:
2668                y.daily[daily] = DailyRecords()
2669            transfer = len(logs[i]) > 1
2670            if debug:
2671                print('logs[i]', logs[i])
2672            for z in logs[i]:
2673                if debug:
2674                    print('z', z)
2675                # daily
2676                value = z.value
2677                if value > 0:
2678                    y.daily[daily].positive += value
2679                else:
2680                    y.daily[daily].negative += -value
2681                y.daily[daily].total += value
2682                z.transfer = transfer
2683                y.daily[daily].rows.append(z)
2684                # weekly
2685                if weekly not in y.weekly:
2686                    y.weekly[weekly] = TimeSummary()
2687                if value > 0:
2688                    y.weekly[weekly].positive += value
2689                else:
2690                    y.weekly[weekly].negative += -value
2691                y.weekly[weekly].total += value
2692                # monthly
2693                if monthly not in y.monthly:
2694                    y.monthly[monthly] = TimeSummary()
2695                if value > 0:
2696                    y.monthly[monthly].positive += value
2697                else:
2698                    y.monthly[monthly].negative += -value
2699                y.monthly[monthly].total += value
2700                # yearly
2701                if yearly not in y.yearly:
2702                    y.yearly[yearly] = TimeSummary()
2703                if value > 0:
2704                    y.yearly[yearly].positive += value
2705                else:
2706                    y.yearly[yearly].negative += -value
2707                y.yearly[yearly].total += value
2708        if debug:
2709            print('y', y)
2710        return y
2711
2712    def add_file(self, account: AccountID, ref: Timestamp, path: str) -> Timestamp:
2713        """
2714        Adds a file reference to a specific transaction log entry in the vault.
2715
2716        Parameters:
2717        - account (AccountID): The account reference associated with the transaction log.
2718        - ref (Timestamp): The reference to the transaction log entry.
2719        - path (str): The path of the file to be added.
2720
2721        Returns:
2722        - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
2723        """
2724        if self.account_exists(account):
2725            if ref in self.__vault.account[account].log:
2726                no_lock = self.nolock()
2727                lock = self.__lock()
2728                file_ref = Time.time()
2729                self.__vault.account[account].log[ref].file[file_ref] = path
2730                self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref)
2731                if no_lock:
2732                    assert lock is not None
2733                    self.free(lock)
2734                return file_ref
2735        return Timestamp(0)
2736
2737    def remove_file(self, account: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool:
2738        """
2739        Removes a file reference from a specific transaction log entry in the vault.
2740
2741        Parameters:
2742        - account (AccountID): The account reference associated with the transaction log.
2743        - ref (Timestamp): The reference to the transaction log entry.
2744        - file_ref (Timestamp): The reference of the file to be removed.
2745
2746        Returns:
2747        - bool: True if the file reference is successfully removed, False otherwise.
2748        """
2749        if self.account_exists(account):
2750            if ref in self.__vault.account[account].log:
2751                if file_ref in self.__vault.account[account].log[ref].file:
2752                    no_lock = self.nolock()
2753                    lock = self.__lock()
2754                    x = self.__vault.account[account].log[ref].file[file_ref]
2755                    del self.__vault.account[account].log[ref].file[file_ref]
2756                    self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
2757                    if no_lock:
2758                        assert lock is not None
2759                        self.free(lock)
2760                    return True
2761        return False
2762
2763    def balance(self, account: AccountID = AccountID('1'), cached: bool = True) -> int:
2764        """
2765        Calculate and return the balance of a specific account.
2766
2767        Parameters:
2768        - account (AccountID, optional): The account reference. Default is '1'.
2769        - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
2770
2771        Returns:
2772        - int: The balance of the account.
2773
2774        Notes:
2775        - If cached is True, the function returns the cached balance.
2776        - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
2777        """
2778        account = AccountID(account)
2779        if cached:
2780            return self.__vault.account[account].balance
2781        x = 0
2782        return [x := x + y.rest for y in self.__vault.account[account].box.values()][-1]
2783
2784    def hide(self, account: AccountID, status: Optional[bool] = None) -> bool:
2785        """
2786        Check or set the hide status of a specific account.
2787
2788        Parameters:
2789        - account (AccountID): The account reference.
2790        - status (bool, optional): The new hide status. If not provided, the function will return the current status.
2791
2792        Returns:
2793        - bool: The current or updated hide status of the account.
2794
2795        Raises:
2796        None
2797
2798        Example:
2799        ```bash
2800        >>> tracker = ZakatTracker()
2801        >>> ref = tracker.track(51, 'desc', 'account1')
2802        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
2803        False
2804        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
2805        True
2806        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
2807        True
2808        >>> tracker.hide('account1', False)
2809        False
2810        ```
2811        """
2812        if self.account_exists(account):
2813            if status is None:
2814                return self.__vault.account[account].hide
2815            self.__vault.account[account].hide = status
2816            return status
2817        return False
2818
2819    def account(self, name: str, exact: bool = True) -> Optional[AccountDetails]:
2820        """
2821        Retrieves an AccountDetails object for the first account matching the given name.
2822
2823        This method searches for accounts with names that contain the provided 'name'
2824        (case-insensitive substring matching). If a match is found, it returns an
2825        AccountDetails object containing the account's ID, name and balance. If no matching
2826        account is found, it returns None.
2827
2828        Parameters:
2829        - name: The name (or partial name) of the account to retrieve.
2830        - exact: If True, performs a case-insensitive exact match.
2831                 If False, performs a case-insensitive substring search.
2832                 Defaults to True.
2833
2834        Returns:
2835        - AccountDetails: An AccountDetails object representing the found account, or None if no
2836            matching account exists.
2837        """
2838        for account_name, account_id in self.names(name).items():
2839            if not exact or account_name.lower() == name.lower():
2840                return AccountDetails(
2841                    account_id=account_id,
2842                    account_name=account_name,
2843                    balance=self.__vault.account[account_id].balance,
2844                )
2845        return None
2846
2847    def create_account(self, name: str) -> AccountID:
2848        """
2849        Creates a new account with the given name and returns its unique ID.
2850
2851        This method:
2852        1. Checks if an account with the same name (case-insensitive) already exists.
2853        2. Generates a unique `AccountID` based on the current time.
2854        3. Tracks the account creation internally.
2855        4. Sets the account's name.
2856        5. Verifies that the name was set correctly.
2857    
2858        Parameters:
2859        - name: The name of the new account.
2860    
2861        Returns:
2862        - AccountID: The unique `AccountID` of the newly created account.
2863    
2864        Raises:
2865        - AssertionError: Empty account name is forbidden.
2866        - AssertionError: Account name in number is forbidden.
2867        - AssertionError: If an account with the same name already exists (case-insensitive).
2868        - AssertionError: If the provided name does not match the name set for the account.
2869        """
2870        assert name.strip(), 'empty account name is forbidden'
2871        assert not name.isdigit() and not name.isdecimal() and not name.isnumeric() and not is_number(name), f'Account name({name}) in number is forbidden'
2872        account_ref = self.account(name, exact=True)
2873        # check if account not exists
2874        assert account_ref is None, f'account name({name}) already used'
2875        # create new account
2876        account_id = AccountID(Time.time())
2877        self.__track(0, '', account_id)
2878        new_name = self.name(
2879            account=account_id,
2880            new_name=name,
2881        )
2882        assert name == new_name
2883        return account_id
2884
2885    def names(self, keyword: str = '') -> dict[str, AccountID]:
2886        """
2887        Retrieves a dictionary of account IDs and names, optionally filtered by a keyword.
2888
2889        Parameters:
2890        - keyword: An optional string to filter account names. If provided, only accounts whose
2891            names contain the keyword (case-insensitive) will be included in the result.
2892            Defaults to an empty string, which returns all accounts.
2893
2894        Returns:
2895        - A dictionary where keys are account names and values are AccountIDs. The dictionary
2896            contains only accounts that match the provided keyword (if any).
2897        """
2898        return {
2899            account.name: account_id
2900            for account_id, account in self.__vault.account.items()
2901            if keyword.lower() in account.name.lower()
2902        }
2903
2904    def name(self, account: AccountID, new_name: Optional[str] = None) -> str:
2905        """
2906        Retrieves or sets the name of an account.
2907
2908        Parameters:
2909        - account: The AccountID of the account.
2910        - new_name: The new name to set for the account. If None, the current name is retrieved.
2911
2912        Returns:
2913        - The current name of the account if `new_name` is None, or the `new_name` if it is set.
2914
2915        Note: Returns an empty string if the account does not exist.
2916        """
2917        if self.account_exists(account):
2918            if new_name is None:
2919                return self.__vault.account[account].name
2920            assert new_name != ''
2921            no_lock = self.nolock()
2922            lock = self.__lock()
2923            self.__step(Action.NAME, account, value=self.__vault.account[account].name)
2924            self.__vault.account[account].name = new_name
2925            if no_lock:
2926                    assert lock is not None
2927                    self.free(lock)
2928            return new_name
2929        return ''
2930
2931    def zakatable(self, account: AccountID, status: Optional[bool] = None) -> bool:
2932        """
2933        Check or set the zakatable status of a specific account.
2934
2935        Parameters:
2936        - account (AccountID): The account reference.
2937        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
2938
2939        Returns:
2940        - bool: The current or updated zakatable status of the account.
2941
2942        Raises:
2943        None
2944
2945        Example:
2946        ```bash
2947        >>> tracker = ZakatTracker()
2948        >>> ref = tracker.track(51, 'desc', 'account1')
2949        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
2950        True
2951        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
2952        True
2953        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
2954        True
2955        >>> tracker.zakatable('account1', False)
2956        False
2957        ```
2958        """
2959        if self.account_exists(account):
2960            if status is None:
2961                return self.__vault.account[account].zakatable
2962            self.__vault.account[account].zakatable = status
2963            return status
2964        return False
2965
2966    def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = AccountID('1'),
2967            created_time_ns: Optional[Timestamp] = None,
2968            debug: bool = False) \
2969            -> SubtractReport:
2970        """
2971        Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance,
2972        the remaining amount will be transferred to a new transaction with a negative value.
2973
2974        Parameters:
2975        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
2976        - desc (str, optional): A description for the transaction. Defaults to an empty string.
2977        - account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'.
2978        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
2979                                           If not provided, the current timestamp will be used.
2980        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
2981
2982        Returns:
2983        - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
2984
2985        Raises:
2986        - ValueError: The unscaled_value should be greater than zero.
2987        - ValueError: The created_time_ns should be greater than zero.
2988        - ValueError: The box transaction happened again in the same nanosecond time.
2989        - ValueError: The log transaction happened again in the same nanosecond time.
2990        """
2991        if debug:
2992            print('sub', f'debug={debug}')
2993        account = AccountID(account)
2994        if unscaled_value <= 0:
2995            raise ValueError('The unscaled_value should be greater than zero.')
2996        if created_time_ns is None:
2997            created_time_ns = Time.time()
2998        if created_time_ns <= 0:
2999            raise ValueError('The created should be greater than zero.')
3000        no_lock = self.nolock()
3001        lock = self.__lock()
3002        self.__track(0, '', account)
3003        value = self.scale(unscaled_value)
3004        self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
3005        ids = sorted(self.__vault.account[account].box.keys())
3006        limit = len(ids) + 1
3007        target = value
3008        if debug:
3009            print('ids', ids)
3010        ages = SubtractAges()
3011        for i in range(-1, -limit, -1):
3012            if target == 0:
3013                break
3014            j = ids[i]
3015            if debug:
3016                print('i', i, 'j', j)
3017            rest = self.__vault.account[account].box[j].rest
3018            if rest >= target:
3019                self.__vault.account[account].box[j].rest -= target
3020                self.__step(Action.SUBTRACT, account, ref=j, value=target)
3021                ages.append(SubtractAge(box_ref=j, total=target))
3022                target = 0
3023                break
3024            elif target > rest > 0:
3025                chunk = rest
3026                target -= chunk
3027                self.__vault.account[account].box[j].rest = 0
3028                self.__step(Action.SUBTRACT, account, ref=j, value=chunk)
3029                ages.append(SubtractAge(box_ref=j, total=chunk))
3030        if target > 0:
3031            self.__track(
3032                unscaled_value=self.unscale(-target),
3033                desc=desc,
3034                account=account,
3035                logging=False,
3036                created_time_ns=created_time_ns,
3037            )
3038            ages.append(SubtractAge(box_ref=created_time_ns, total=target))
3039        if no_lock:
3040            assert lock is not None
3041            self.free(lock)
3042        return SubtractReport(
3043            log_ref=created_time_ns,
3044            ages=ages,
3045        )
3046
3047    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '',
3048                 created_time_ns: Optional[Timestamp] = None,
3049                 debug: bool = False) -> Optional[TransferReport]:
3050        """
3051        Transfers a specified value from one account to another.
3052
3053        Parameters:
3054        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
3055        - from_account (AccountID): The account reference from which the value will be transferred.
3056        - to_account (AccountID): The account reference to which the value will be transferred.
3057        - desc (str, optional): A description for the transaction. Defaults to an empty string.
3058        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
3059        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
3060
3061        Returns:
3062        - Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer.
3063
3064        Raises:
3065        - ValueError: Transfer to the same account is forbidden.
3066        - ValueError: The created_time_ns should be greater than zero.
3067        - ValueError: The box transaction happened again in the same nanosecond time.
3068        - ValueError: The log transaction happened again in the same nanosecond time.
3069        """
3070        if debug:
3071            print('transfer', f'debug={debug}')
3072        from_account = AccountID(from_account)
3073        to_account = AccountID(to_account)
3074        if from_account == to_account:
3075            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
3076        if unscaled_amount <= 0:
3077            return None
3078        if created_time_ns is None:
3079            created_time_ns = Time.time()
3080        if created_time_ns <= 0:
3081            raise ValueError('The created should be greater than zero.')
3082        no_lock = self.nolock()
3083        lock = self.__lock()
3084        subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug)
3085        source_exchange = self.exchange(from_account, created_time_ns)
3086        target_exchange = self.exchange(to_account, created_time_ns)
3087
3088        if debug:
3089            print('ages', subtract_report.ages)
3090
3091        transfer_report = TransferReport()
3092        for subtract in subtract_report.ages:
3093            times = TransferTimes()
3094            age = subtract.box_ref
3095            value = subtract.total
3096            assert source_exchange.rate is not None
3097            assert target_exchange.rate is not None
3098            target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate))
3099            if debug:
3100                print('target_amount', target_amount)
3101            # Perform the transfer
3102            if self.box_exists(to_account, age):
3103                if debug:
3104                    print('box_exists', age)
3105                capital = self.__vault.account[to_account].box[age].capital
3106                rest = self.__vault.account[to_account].box[age].rest
3107                if debug:
3108                    print(
3109                        f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3110                selected_age = age
3111                if rest + target_amount > capital:
3112                    self.__vault.account[to_account].box[age].capital += target_amount
3113                    selected_age = Time.time()
3114                self.__vault.account[to_account].box[age].rest += target_amount
3115                self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
3116                y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
3117                              created_time_ns=None, ref=None, debug=debug)
3118                times.append(TransferTime(box_ref=age, log_ref=y))
3119                continue
3120            if debug:
3121                print(
3122                    f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3123            box_ref = self.__track(
3124                unscaled_value=self.unscale(int(target_amount)),
3125                desc=desc,
3126                account=to_account,
3127                logging=True,
3128                created_time_ns=age,
3129                debug=debug,
3130            )
3131            transfer_report.append(TransferRecord(
3132                box_ref=box_ref,
3133                times=times,
3134            ))
3135        if no_lock:
3136            assert lock is not None
3137            self.free(lock)
3138        return transfer_report
3139    
3140    def check(self,
3141              silver_gram_price: float,
3142              unscaled_nisab: Optional[float | int | decimal.Decimal] = None,
3143              debug: bool = False,
3144              created_time_ns: Optional[Timestamp] = None,
3145              cycle: Optional[float] = None) -> ZakatReport:
3146        """
3147        Check the eligibility for Zakat based on the given parameters.
3148
3149        Parameters:
3150        - silver_gram_price (float): The price of a gram of silver.
3151        - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat.
3152                        If not provided, it will be calculated based on the silver_gram_price.
3153        - debug (bool, optional): Flag to enable debug mode.
3154        - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
3155        - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
3156
3157        Returns:
3158        - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat,
3159            a list of brief statistics, and a dictionary containing the Zakat plan.
3160        """
3161        if debug:
3162            print('check', f'debug={debug}')
3163        before_parameters = {
3164            "silver_gram_price": silver_gram_price,
3165            "unscaled_nisab": unscaled_nisab,
3166            "debug": debug,
3167            "created_time_ns": created_time_ns,
3168            "cycle": cycle,
3169        }
3170        if created_time_ns is None:
3171            created_time_ns = Time.time()
3172        if cycle is None:
3173            cycle = ZakatTracker.TimeCycle()
3174        if unscaled_nisab is None:
3175            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
3176        nisab = self.scale(unscaled_nisab)
3177        plan: dict[AccountID, list[BoxPlan]] = {}
3178        summary = ZakatSummary()
3179        below_nisab = 0
3180        valid = False
3181        after_parameters = {
3182            "silver_gram_price": silver_gram_price,
3183            "unscaled_nisab": unscaled_nisab,
3184            "debug": debug,
3185            "created_time_ns": created_time_ns,
3186            "cycle": cycle,
3187        }
3188        if debug:
3189            print('exchanges', self.exchanges())
3190        for x in self.__vault.account:
3191            if not self.zakatable(x):
3192                continue
3193            _box = self.__vault.account[x].box
3194            _log = self.__vault.account[x].log
3195            limit = len(_box) + 1
3196            ids = sorted(self.__vault.account[x].box.keys())
3197            for i in range(-1, -limit, -1):
3198                j = ids[i]
3199                rest = float(_box[j].rest)
3200                if rest <= 0:
3201                    continue
3202                exchange = self.exchange(x, created_time_ns=Time.time())
3203                assert exchange.rate is not None
3204                rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1)
3205                summary.num_wealth_items += 1
3206                summary.total_wealth += rest
3207                epoch = (created_time_ns - j) / cycle
3208                if debug:
3209                    print(f'Epoch: {epoch}', _box[j])
3210                if _box[j].zakat.last > 0:
3211                    epoch = (created_time_ns - _box[j].zakat.last) / cycle
3212                if debug:
3213                    print(f'Epoch: {epoch}')
3214                epoch = math.floor(epoch)
3215                if debug:
3216                    print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch)
3217                if epoch == 0:
3218                    continue
3219                if debug:
3220                    print('Epoch - PASSED')
3221                summary.num_zakatable_items += 1
3222                summary.total_zakatable_amount += rest
3223                is_nisab = rest >= nisab
3224                total = 0
3225                if is_nisab:
3226                    for _ in range(epoch):
3227                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
3228                    valid = total > 0
3229                elif rest > 0:
3230                    below_nisab += rest
3231                    total = ZakatTracker.ZakatCut(float(rest))
3232                if total > 0:
3233                    if x not in plan:
3234                        plan[x] = []
3235                    summary.total_zakat_due += total
3236                    plan[x].append(BoxPlan(
3237                        below_nisab=not is_nisab,
3238                        total=total,
3239                        count=epoch,
3240                        ref=j,
3241                        box=_box[j],
3242                        log=_log[j],
3243                        exchange=exchange,
3244                    ))
3245        valid = valid or below_nisab >= nisab
3246        if debug:
3247            print(f'below_nisab({below_nisab}) >= nisab({nisab})')
3248        report = ZakatReport(
3249            created=Time.time(),
3250            valid=valid,
3251            summary=summary,
3252            plan=plan,
3253            parameters={
3254                'before': before_parameters,
3255                'after': after_parameters,
3256            },
3257        )
3258        self.__vault.cache.zakat = report if valid else None
3259        return report
3260
3261    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
3262        """
3263        Build payment parts for the Zakat distribution.
3264
3265        Parameters:
3266        - scaled_demand (int): The total demand for payment in local currency.
3267        - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
3268
3269        Returns:
3270        - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure:
3271        {
3272            'account': {
3273                'account_id': {'balance': float, 'rate': float, 'part': float},
3274                ...
3275            },
3276            'exceed': bool,
3277            'demand': int,
3278            'total': float,
3279        }
3280        """
3281        total = 0.0
3282        parts = PaymentParts(
3283            account={},
3284            exceed=False,
3285            demand=int(round(scaled_demand)),
3286            total=0,
3287        )
3288        for x, y in self.accounts().items():
3289            if positive_only and y.balance <= 0:
3290                continue
3291            total += float(y.balance)
3292            exchange = self.exchange(x)
3293            parts.account[x] = AccountPaymentPart(balance=y.balance, rate=exchange.rate, part=0)
3294        parts.total = total
3295        return parts
3296
3297    @staticmethod
3298    def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int:
3299        """
3300        Checks the validity of payment parts.
3301
3302        Parameters:
3303        - parts (dict[str, PaymentParts): A dictionary containing payment parts information.
3304        - debug (bool, optional): Flag to enable debug mode.
3305
3306        Returns:
3307        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
3308
3309        Error Codes:
3310        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
3311        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
3312        3: 'part' value in parts['account'][x] is less than 0.
3313        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
3314        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
3315        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
3316        """
3317        if debug:
3318            print('check_payment_parts', f'debug={debug}')
3319        # for i in ['demand', 'account', 'total', 'exceed']:
3320        #     if i not in parts:
3321        #         return 1
3322        exceed = parts.exceed
3323        # for j in ['balance', 'rate', 'part']:
3324        #     if j not in parts.account[x]:
3325        #         return 2
3326        for x in parts.account:
3327            if parts.account[x].part < 0:
3328                return 3
3329            if not exceed and parts.account[x].balance <= 0:
3330                return 4
3331        demand = parts.demand
3332        z = 0.0
3333        for _, y in parts.account.items():
3334            if not exceed and y.part > y.balance:
3335                return 5
3336            z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0)
3337        z = round(z, 2)
3338        demand = round(demand, 2)
3339        if debug:
3340            print('check_payment_parts', f'z = {z}, demand = {demand}')
3341            print('check_payment_parts', type(z), type(demand))
3342            print('check_payment_parts', z != demand)
3343            print('check_payment_parts', str(z) != str(demand))
3344        if z != demand and str(z) != str(demand):
3345            return 6
3346        return 0
3347
3348    def zakat(self, report: ZakatReport,
3349        parts: Optional[PaymentParts] = None, debug: bool = False) -> bool:
3350        """
3351        Perform Zakat calculation based on the given report and optional parts.
3352
3353        Parameters:
3354        - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
3355        - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
3356        - debug (bool, optional): A flag indicating whether to print debug information.
3357
3358        Returns:
3359        - bool: True if the zakat calculation is successful, False otherwise.
3360
3361        Raises:
3362        - AssertionError: Bad Zakat report, call `check` first then call `zakat`.
3363        """
3364        if debug:
3365            print('zakat', f'debug={debug}')
3366        if not report.valid:
3367            return report.valid
3368        assert report.plan
3369        parts_exist = parts is not None
3370        if parts_exist:
3371            if self.check_payment_parts(parts, debug=debug) != 0:
3372                return False
3373        if debug:
3374            print('######### zakat #######')
3375            print('parts_exist', parts_exist)
3376        assert report == self.__vault.cache.zakat, "Bad Zakat report, call `check` first then call `zakat`"
3377        no_lock = self.nolock()
3378        lock = self.__lock()
3379        report_time = Time.time()
3380        self.__vault.report[report_time] = report
3381        self.__step(Action.REPORT, ref=report_time)
3382        created_time_ns = Time.time()
3383        for x in report.plan:
3384            target_exchange = self.exchange(x)
3385            if debug:
3386                print(report.plan[x])
3387                print('-------------')
3388                print(self.__vault.account[x].box)
3389            if debug:
3390                print('plan[x]', report.plan[x])
3391            for plan in report.plan[x]:
3392                j = plan.ref
3393                if debug:
3394                    print('j', j)
3395                assert j
3396                self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].zakat.last,
3397                           key='last',
3398                           math_operation=MathOperation.EQUAL)
3399                self.__vault.account[x].box[j].zakat.last = created_time_ns
3400                assert target_exchange.rate is not None
3401                amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate))
3402                self.__vault.account[x].box[j].zakat.total += amount
3403                self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
3404                           math_operation=MathOperation.ADDITION)
3405                self.__vault.account[x].box[j].zakat.count += plan.count
3406                self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count',
3407                           math_operation=MathOperation.ADDITION)
3408                if not parts_exist:
3409                    try:
3410                        self.__vault.account[x].box[j].rest -= amount
3411                    except TypeError:
3412                        self.__vault.account[x].box[j].rest -= decimal.Decimal(amount)
3413                    # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
3414                    #            math_operation=MathOperation.SUBTRACTION)
3415                    self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug)
3416        if parts_exist:
3417            for account, part in parts.account.items():
3418                if part.part == 0:
3419                    continue
3420                if debug:
3421                    print('zakat-part', account, part.rate)
3422                target_exchange = self.exchange(account)
3423                assert target_exchange.rate is not None
3424                amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate)
3425                unscaled_amount = self.unscale(int(amount))
3426                if unscaled_amount <= 0:
3427                    if debug:
3428                        print(f"The amount({unscaled_amount:.20f}) it was {amount:.20f} should be greater tha zero.")
3429                    continue
3430                self.subtract(
3431                    unscaled_value=unscaled_amount,
3432                    desc='zakat-part-دفعة-زكاة',
3433                    account=account,
3434                    debug=debug,
3435                )
3436        if no_lock:
3437            assert lock is not None
3438            self.free(lock)
3439        self.__vault.cache.zakat = None
3440        return True
3441
3442    @staticmethod
3443    def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
3444        """Splits a string at the last occurrence of a given symbol.
3445    
3446        Parameters:
3447        - data (str): The input string.
3448        - symbol (str): The symbol to split at.
3449    
3450        Returns:
3451        - tuple[str, str]: A tuple containing two strings, the part before the last symbol and
3452            the part after the last symbol. If the symbol is not found, returns (data, "").
3453        """
3454        last_symbol_index = data.rfind(symbol)
3455    
3456        if last_symbol_index != -1:
3457            before_symbol = data[:last_symbol_index]
3458            after_symbol = data[last_symbol_index + len(symbol):]
3459            return before_symbol, after_symbol
3460        return data, ""
3461
3462    def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool:
3463        """
3464        Saves the ZakatTracker's current state to a json file.
3465
3466        This method serializes the internal data (`__vault`).
3467
3468        Parameters:
3469        - path (str, optional): File path for saving. Defaults to a predefined location.
3470        - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.
3471
3472        Returns:
3473        - bool: True if the save operation is successful, False otherwise.
3474        """
3475        if path is None:
3476            path = self.path()
3477        # first save in tmp file
3478        temp = f'{path}.tmp'
3479        try:
3480            with open(temp, 'w', encoding='utf-8') as stream:
3481                data = json.dumps(self.__vault, cls=JSONEncoder)
3482                stream.write(data)
3483                if hash_required:
3484                    hashed = self.hash_data(data.encode())
3485                    stream.write(f'//{hashed}')
3486            # then move tmp file to original location
3487            shutil.move(temp, path)
3488            return True
3489        except (IOError, OSError) as e:
3490            print(f'Error saving file: {e}')
3491            if os.path.exists(temp):
3492                os.remove(temp)
3493            return False
3494
3495    @staticmethod
3496    def load_vault_from_json(json_string: str) -> Vault:
3497        """Loads a Vault dataclass from a JSON string."""
3498        data = json.loads(json_string)
3499
3500        vault = Vault()
3501
3502        # Load Accounts
3503        for account_reference, account_data in data.get("account", {}).items():
3504            account_reference = AccountID(account_reference)
3505            box_data = account_data.get('box', {})
3506            box = {
3507                Timestamp(ts): Box(
3508                    capital=box_data[str(ts)]["capital"],
3509                    rest=box_data[str(ts)]["rest"],
3510                    zakat=BoxZakat(**box_data[str(ts)]["zakat"]),
3511                )
3512                for ts in box_data
3513            }
3514
3515            log_data = account_data.get('log', {})
3516            log = {Timestamp(ts): Log(
3517                value=log_data[str(ts)]['value'],
3518                desc=log_data[str(ts)]['desc'],
3519                ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None,
3520                file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()},
3521            ) for ts in log_data}
3522
3523            vault.account[account_reference] = Account(
3524                balance=account_data["balance"],
3525                created=Timestamp(account_data["created"]),
3526                name=account_data.get("name", ""),
3527                box=box,
3528                count=account_data.get("count", 0),
3529                log=log,
3530                hide=account_data.get("hide", False),
3531                zakatable=account_data.get("zakatable", True),
3532            )
3533
3534        # Load Exchanges
3535        for account_reference, exchange_data in data.get("exchange", {}).items():
3536            account_reference = AccountID(account_reference)
3537            vault.exchange[account_reference] = {}
3538            for timestamp, exchange_details in exchange_data.items():
3539                vault.exchange[account_reference][Timestamp(timestamp)] = Exchange(
3540                    rate=exchange_details.get("rate"),
3541                    description=exchange_details.get("description"),
3542                    time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None,
3543                )
3544
3545        # Load History
3546        for timestamp, history_dict in data.get("history", {}).items():
3547            vault.history[Timestamp(timestamp)] = {}
3548            for history_key, history_data in history_dict.items():
3549                vault.history[Timestamp(timestamp)][Timestamp(history_key)] = History(
3550                    action=Action(history_data["action"]),
3551                    account=AccountID(history_data["account"]) if history_data.get("account") is not None else None,
3552                    ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None,
3553                    file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None,
3554                    key=history_data.get("key"),
3555                    value=history_data.get("value"),
3556                    math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None,
3557                )
3558
3559        # Load Lock
3560        vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None
3561
3562        # Load Report
3563        for timestamp, report_data in data.get("report", {}).items():
3564            zakat_plan: dict[AccountID, list[BoxPlan]] = {}
3565            for account_reference, box_plans in report_data.get("plan", {}).items():
3566                account_reference = AccountID(account_reference)
3567                zakat_plan[account_reference] = []
3568                for box_plan_data in box_plans:
3569                    zakat_plan[account_reference].append(BoxPlan(
3570                        box=Box(
3571                            capital=box_plan_data["box"]["capital"],
3572                            rest=box_plan_data["box"]["rest"],
3573                            zakat=BoxZakat(**box_plan_data["box"]["zakat"]),
3574                        ),
3575                        log=Log(**box_plan_data["log"]),
3576                        exchange=Exchange(**box_plan_data["exchange"]),
3577                        below_nisab=box_plan_data["below_nisab"],
3578                        total=box_plan_data["total"],
3579                        count=box_plan_data["count"],
3580                        ref=Timestamp(box_plan_data["ref"]),
3581                    ))
3582
3583            vault.report[Timestamp(timestamp)] = ZakatReport(
3584                created=report_data["created"],
3585                valid=report_data["valid"],
3586                summary=ZakatSummary(**report_data["summary"]),
3587                plan=zakat_plan,
3588                parameters=report_data["parameters"],
3589            )
3590
3591        # Load Cache
3592        vault.cache = Cache()
3593        cache_data = data.get("cache", {})
3594        if "zakat" in cache_data:
3595            cache_zakat_data = cache_data.get("zakat", {})
3596            if cache_zakat_data:
3597                zakat_plan: dict[AccountID, list[BoxPlan]] = {}
3598                for account_reference, box_plans in cache_zakat_data.get("plan", {}).items():
3599                    account_reference = AccountID(account_reference)
3600                    zakat_plan[account_reference] = []
3601                    for box_plan_data in box_plans:
3602                        zakat_plan[account_reference].append(BoxPlan(
3603                            box=Box(
3604                                capital=box_plan_data["box"]["capital"],
3605                                rest=box_plan_data["box"]["rest"],
3606                                zakat=BoxZakat(**box_plan_data["box"]["zakat"]),
3607                            ),
3608                            log=Log(**box_plan_data["log"]),
3609                            exchange=Exchange(**box_plan_data["exchange"]),
3610                            below_nisab=box_plan_data["below_nisab"],
3611                            total=box_plan_data["total"],
3612                            count=box_plan_data["count"],
3613                            ref=Timestamp(box_plan_data["ref"]),
3614                        ))
3615
3616                vault.cache.zakat = ZakatReport(
3617                    created=cache_zakat_data["created"],
3618                    valid=cache_zakat_data["valid"],
3619                    summary=ZakatSummary(**cache_zakat_data["summary"]),
3620                    plan=zakat_plan,
3621                    parameters=cache_zakat_data["parameters"],
3622                )
3623
3624        return vault
3625
3626    def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool:
3627        """
3628        Load the current state of the ZakatTracker object from a json file.
3629
3630        Parameters:
3631        - path (str, optional): The path where the json file is located. If not provided, it will use the default path.
3632        - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
3633        - debug (bool, optional): Flag to enable debug mode.
3634
3635        Returns:
3636        - bool: True if the load operation is successful, False otherwise.
3637        """
3638        if path is None:
3639            path = self.path()
3640        try:
3641            if os.path.exists(path):
3642                with open(path, 'r', encoding='utf-8') as stream:
3643                    file = stream.read()
3644                    data, hashed = self.split_at_last_symbol(file, '//')
3645                    if hash_required:
3646                        assert hashed
3647                        if debug:
3648                            print('[debug-load]', hashed)
3649                        new_hash = self.hash_data(data.encode())
3650                        if debug:
3651                            print('[debug-load]', new_hash)
3652                        assert hashed == new_hash, "Hash verification failed. File may be corrupted."
3653                    self.__vault = self.load_vault_from_json(data)
3654                return True
3655            else:
3656                print(f'File not found: {path}')
3657                return False
3658        except (IOError, OSError) as e:
3659            print(f'Error loading file: {e}')
3660            return False
3661
3662    def import_csv_cache_path(self):
3663        """
3664        Generates the cache file path for imported CSV data.
3665
3666        This function constructs the file path where cached data from CSV imports
3667        will be stored. The cache file is a json file (.json extension) appended
3668        to the base path of the object.
3669
3670        Parameters:
3671        None
3672
3673        Returns:
3674        - str: The full path to the import CSV cache file.
3675
3676        Example:
3677        ```bash
3678        >>> obj = ZakatTracker('/data/reports')
3679        >>> obj.import_csv_cache_path()
3680        '/data/reports.import_csv.json'
3681        ```
3682        """
3683        path = str(self.path())
3684        ext = self.ext()
3685        ext_len = len(ext)
3686        if path.endswith(f'.{ext}'):
3687            path = path[:-ext_len - 1]
3688        _, filename = os.path.split(path + f'.import_csv.{ext}')
3689        return self.base_path(filename)
3690
3691    @staticmethod
3692    def get_transaction_csv_headers() -> list[str]:
3693        """
3694        Returns a list of strings representing the headers for a transaction CSV file.
3695
3696        The headers include:
3697        - account: The account associated with the transaction.
3698        - desc: A description of the transaction.
3699        - value: The monetary value of the transaction.
3700        - date: The date of the transaction.
3701        - rate: The applicable rate (if any) for the transaction.
3702        - reference: An optional reference number or identifier for the transaction.
3703
3704        Returns:
3705        - list[str]: A list containing the CSV header strings.
3706        """
3707        return [
3708            "account",
3709            "desc",
3710            "value",
3711            "date",
3712            "rate",
3713            "reference",
3714        ]
3715
3716    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> ImportReport:
3717        """
3718        The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system.
3719
3720        Parameters:
3721        - path (str, optional): The path to the CSV file. Default is 'file.csv'.
3722        - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
3723        - debug (bool, optional): A flag indicating whether to print debug information.
3724
3725        Returns:
3726        - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache,
3727                and a dictionary of bad transactions.
3728
3729        Notes:
3730        * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
3731                                    are appropriate for the currency pairs involved in the conversions.
3732        * The exchange rate for each account is based on the last encountered transaction rate that is not equal
3733            to 1.0 or the previous rate for that account.
3734        * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
3735            transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and
3736            `zakat` operations.
3737
3738        Example:
3739            The CSV file should have the following format, rate and reference are optionals per transaction:
3740            account, desc, value, date, rate, reference
3741            For example:
3742            safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554
3743        """
3744        if debug:
3745            print('import_csv', f'debug={debug}')
3746        cache: list[int] = []
3747        try:
3748            if not self.memory_mode():
3749                with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream:
3750                    cache = json.load(stream)
3751        except Exception as e:
3752            if debug:
3753                print(e)
3754        date_formats = [
3755            '%Y-%m-%d %H:%M:%S.%f',
3756            '%Y-%m-%dT%H:%M:%S.%f',
3757            '%Y-%m-%dT%H%M%S.%f',
3758            '%Y-%m-%d',
3759        ]
3760        statistics = ImportStatistics(0, 0, 0)
3761        data: dict[int, list[CSVRecord]] = {}
3762        with open(path, newline='', encoding='utf-8') as f:
3763            i = 0
3764            for row in csv.reader(f, delimiter=','):
3765                if debug:
3766                    print(f"csv_row({i})", row, type(row))
3767                if row == self.get_transaction_csv_headers():
3768                    continue
3769                i += 1
3770                hashed = hash(tuple(row))
3771                if hashed in cache:
3772                    statistics.found += 1
3773                    continue
3774                account = row[0]
3775                desc = row[1]
3776                value = float(row[2])
3777                rate = 1.0
3778                reference = ''
3779                if row[4:5]: # Empty list if index is out of range
3780                    rate = float(row[4])
3781                if row[5:6]:
3782                    reference = row[5]
3783                date: int = 0
3784                for time_format in date_formats:
3785                    try:
3786                        date_str = row[3]
3787                        if "." not in date_str:
3788                            date_str += ".000000"
3789                        date = Time.time(datetime.datetime.strptime(date_str, time_format))
3790                        break
3791                    except Exception as e:
3792                        if debug:
3793                            print(e)
3794                record = CSVRecord(
3795                    index=i,
3796                    account=account,
3797                    desc=desc,
3798                    value=value,
3799                    date=date,
3800                    rate=rate,
3801                    reference=reference,
3802                    hashed=hashed,
3803                    error='',
3804                )
3805                if date <= 0:
3806                    record.error = 'invalid date'
3807                    statistics.bad += 1
3808                if value == 0:
3809                    record.error = 'invalid value'
3810                    statistics.bad += 1
3811                    continue
3812                if date not in data:
3813                    data[date] = []
3814                data[date].append(record)
3815
3816        if debug:
3817            print('import_csv', len(data))
3818
3819        if statistics.bad > 0:
3820            return ImportReport(
3821                statistics=statistics,
3822                bad=[
3823                    item
3824                    for sublist in data.values()
3825                    for item in sublist
3826                    if item.error
3827                ],
3828            )
3829
3830        no_lock = self.nolock()
3831        lock = self.__lock()
3832        names = self.names()
3833
3834        # sync accounts
3835        if debug:
3836            print('before-names', names, len(names))
3837        for date, rows in sorted(data.items()):
3838            new_rows: list[CSVRecord] = []
3839            for row in rows:
3840                if row.account not in names:
3841                    account_id = self.create_account(row.account)
3842                    names[row.account] = account_id
3843                account_id = names[row.account]
3844                assert account_id
3845                row.account = account_id
3846                new_rows.append(row)
3847            assert new_rows
3848            assert date in data
3849            data[date] = new_rows
3850        if debug:
3851            print('after-names', names, len(names))
3852            assert names == self.names()
3853
3854        # do ops
3855        for date, rows in sorted(data.items()):
3856            try:
3857                def process(x: CSVRecord):
3858                    x.value = self.unscale(
3859                        x.value,
3860                        decimal_places=scale_decimal_places,
3861                    ) if scale_decimal_places > 0 else x.value
3862                    if x.rate > 0:
3863                        self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate)
3864                    if x.value > 0:
3865                        self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3866                    elif x.value < 0:
3867                        self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3868                    return x.hashed
3869                len_rows = len(rows)
3870                # If records are found at the same time with different accounts in the same amount
3871                # (one positive and the other negative), this indicates it is a transfer.
3872                if len_rows > 2 or len_rows == 1:
3873                    for row in rows:
3874                        hashed = process(row)
3875                        assert hashed not in cache
3876                        cache.append(hashed)
3877                        statistics.created += 1
3878                    continue
3879                x1 = rows[0]
3880                x2 = rows[1]
3881                if x1.account == x2.account:
3882                    continue
3883                    # raise Exception(f'invalid transfer')
3884                # not transfer - same time - normal ops
3885                if abs(x1.value) != abs(x2.value) and x1.date == x2.date:
3886                    rows[1].date += 1
3887                    for row in rows:
3888                        hashed = process(row)
3889                        assert hashed not in cache
3890                        cache.append(hashed)
3891                        statistics.created += 1
3892                    continue
3893                if x1.rate > 0:
3894                    self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate)
3895                if x2.rate > 0:
3896                    self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate)
3897                x1.value = self.unscale(
3898                    x1.value,
3899                    decimal_places=scale_decimal_places,
3900                ) if scale_decimal_places > 0 else x1.value
3901                x2.value = self.unscale(
3902                    x2.value,
3903                    decimal_places=scale_decimal_places,
3904                ) if scale_decimal_places > 0 else x2.value
3905                # just transfer
3906                values = {
3907                    x1.value: x1.account,
3908                    x2.value: x2.account,
3909                }
3910                if debug:
3911                    print('values', values)
3912                if len(values) <= 1:
3913                    continue
3914                self.transfer(
3915                    unscaled_amount=abs(x1.value),
3916                    from_account=values[min(values.keys())],
3917                    to_account=values[max(values.keys())],
3918                    desc=x1.desc,
3919                    created_time_ns=x1.date,
3920                )
3921            except Exception as e:
3922                for row in rows:
3923                    _tuple = tuple()
3924                    for field in row:
3925                        _tuple += (field,)
3926                    _tuple += (e,)
3927                    bad[i] = _tuple
3928                break
3929        if not self.memory_mode():
3930            with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream:
3931                stream.write(json.dumps(cache))
3932        if no_lock:
3933            assert lock is not None
3934            self.free(lock)
3935        report = ImportReport(
3936            statistics=statistics,
3937            bad=[
3938                item
3939                for sublist in data.values()
3940                for item in sublist
3941                if item.error
3942            ],
3943        )
3944        if debug:
3945            debug_path = f'{self.import_csv_cache_path()}.debug.json'
3946            with open(debug_path, 'w', encoding='utf-8') as file:
3947                json.dump(report, file, indent=4, cls=JSONEncoder)
3948                print(f'generated debug report @ `{debug_path}`...')
3949        return report
3950
3951    ########
3952    # TESTS #
3953    #######
3954
3955    @staticmethod
3956    def human_readable_size(size: float, decimal_places: int = 2) -> str:
3957        """
3958        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
3959
3960        This function iterates through progressively larger units of information
3961        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
3962        range that can be expressed with a reasonable number before the unit.
3963
3964        Parameters:
3965        - size (float): The size in bytes to convert.
3966        - decimal_places (int, optional): The number of decimal places to display
3967            in the result. Defaults to 2.
3968
3969        Returns:
3970        - str: A string representation of the size in a human-readable format,
3971            rounded to the specified number of decimal places. For example:
3972                - '1.50 KB' (1536 bytes)
3973                - '23.00 MB' (24117248 bytes)
3974                - '1.23 GB' (1325899906 bytes)
3975        """
3976        if type(size) not in (float, int):
3977            raise TypeError('size must be a float or integer')
3978        if type(decimal_places) != int:
3979            raise TypeError('decimal_places must be an integer')
3980        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
3981            if size < 1024.0:
3982                break
3983            size /= 1024.0
3984        return f'{size:.{decimal_places}f} {unit}'
3985
3986    @staticmethod
3987    def get_dict_size(obj: dict, seen: Optional[set] = None) -> float:
3988        """
3989        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
3990
3991        This function traverses the dictionary structure, accounting for the size of keys, values,
3992        and any nested objects. It handles various data types commonly found in dictionaries
3993        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
3994        of circular references.
3995
3996        Parameters:
3997        - obj (dict): The dictionary whose size is to be calculated.
3998        - seen (set, optional): A set used internally to track visited objects
3999                             and avoid circular references. Defaults to None.
4000
4001        Returns:
4002         - float: An approximate size of the dictionary and its contents in bytes.
4003
4004        Notes:
4005        - This function is a method of the `ZakatTracker` class and is likely used to
4006          estimate the memory footprint of data structures relevant to Zakat calculations.
4007        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
4008          not account for all memory overhead depending on the Python implementation.
4009        - Circular references are handled to prevent infinite recursion.
4010        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
4011        - String sizes are estimated based on character length and encoding.
4012        """
4013        size = 0
4014        if seen is None:
4015            seen = set()
4016
4017        obj_id = id(obj)
4018        if obj_id in seen:
4019            return 0
4020
4021        seen.add(obj_id)
4022        size += sys.getsizeof(obj)
4023
4024        if isinstance(obj, dict):
4025            for k, v in obj.items():
4026                size += ZakatTracker.get_dict_size(k, seen)
4027                size += ZakatTracker.get_dict_size(v, seen)
4028        elif isinstance(obj, (list, tuple, set, frozenset)):
4029            for item in obj:
4030                size += ZakatTracker.get_dict_size(item, seen)
4031        elif isinstance(obj, (int, float, complex)):  # Handle numbers
4032            pass  # Basic numbers have a fixed size, so nothing to add here
4033        elif isinstance(obj, str):  # Handle strings
4034            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
4035        return size
4036
4037    @staticmethod
4038    def day_to_time(day: int, month: int = 6, year: int = 2024) -> Timestamp:  # افتراض أن الشهر هو يونيو والسنة 2024
4039        """
4040        Convert a specific day, month, and year into a timestamp.
4041
4042        Parameters:
4043        - day (int): The day of the month.
4044        - month (int, optional): The month of the year. Default is 6 (June).
4045        - year (int, optional): The year. Default is 2024.
4046
4047        Returns:
4048        - Timestamp: The timestamp representing the given day, month, and year.
4049
4050        Note:
4051        - This method assumes the default month and year if not provided.
4052        """
4053        return Time.time(datetime.datetime(year, month, day))
4054
4055    @staticmethod
4056    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
4057        """
4058        Generate a random date between two given dates.
4059
4060        Parameters:
4061        - start_date (datetime.datetime): The start date from which to generate a random date.
4062        - end_date (datetime.datetime): The end date until which to generate a random date.
4063
4064        Returns:
4065        - datetime.datetime: A random date between the start_date and end_date.
4066        """
4067        time_between_dates = end_date - start_date
4068        days_between_dates = time_between_dates.days
4069        random_number_of_days = random.randrange(days_between_dates)
4070        return start_date + datetime.timedelta(days=random_number_of_days)
4071
4072    @staticmethod
4073    def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False,
4074                                 debug: bool = False) -> int:
4075        """
4076        Generate a random CSV file with specified parameters.
4077        The function generates a CSV file at the specified path with the given count of rows.
4078        Each row contains a randomly generated account, description, value, and date.
4079        The value is randomly generated between 1000 and 100000,
4080        and the date is randomly generated between 1950-01-01 and 2023-12-31.
4081        If the row number is not divisible by 13, the value is multiplied by -1.
4082
4083        Parameters:
4084        - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
4085        - count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
4086        - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
4087        - debug (bool, optional): A flag indicating whether to print debug information.
4088
4089        Returns:
4090        - int: number of generated records.
4091        """
4092        if debug:
4093            print('generate_random_csv_file', f'debug={debug}')
4094        i = 0
4095        with open(path, 'w', newline='', encoding='utf-8') as csvfile:
4096            writer = csv.writer(csvfile)
4097            writer.writerow(ZakatTracker.get_transaction_csv_headers())
4098            for i in range(count):
4099                account = f'acc-{random.randint(1, count)}'
4100                desc = f'Some text {random.randint(1, count)}'
4101                value = random.randint(1000, 100000)
4102                date = ZakatTracker.generate_random_date(
4103                    datetime.datetime(1000, 1, 1),
4104                    datetime.datetime(2023, 12, 31),
4105                ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S')
4106                if not i % 13 == 0:
4107                    value *= -1
4108                row = [account, desc, value, date]
4109                if with_rate:
4110                    rate = random.randint(1, 100) * 0.12
4111                    if debug:
4112                        print('before-append', row)
4113                    row.append(rate)
4114                    if debug:
4115                        print('after-append', row)
4116                if i % 2 == 1:
4117                    row += (Time.time(),)
4118                writer.writerow(row)
4119                i = i + 1
4120        return i
4121
4122    @staticmethod
4123    def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
4124        """
4125        Creates a list of random integers whose sum does not exceed the specified maximum.
4126
4127        Parameters:
4128        - max_sum (int): The maximum allowed sum of the list elements.
4129        - min_value (int, optional): The minimum possible value for an element (inclusive).
4130        - max_value (int, optional): The maximum possible value for an element (inclusive).
4131
4132        Returns:
4133        - A list of random integers.
4134        """
4135        result = []
4136        current_sum = 0
4137
4138        while current_sum < max_sum:
4139            # Calculate the remaining space for the next element
4140            remaining_sum = max_sum - current_sum
4141            # Determine the maximum possible value for the next element
4142            next_max_value = min(remaining_sum, max_value)
4143            # Generate a random element within the allowed range
4144            next_element = random.randint(min_value, next_max_value)
4145            result.append(next_element)
4146            current_sum += next_element
4147
4148        return result
4149
4150    def backup(self, folder_path: str, output_directory: str = "compressed", debug: bool = False) -> Optional[Backup]:
4151        """
4152        Compresses a folder into a .tar.lzma archive.
4153
4154        The archive is named following a specific format:
4155        'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'.  This format
4156        is crucial for the `restore` function, so avoid renaming the files.
4157
4158        Parameters:
4159        - folder_path (str): The path to the folder to be compressed.
4160        - output_directory (str, optional): The directory to save the compressed file.
4161                                        Defaults to "compressed".
4162        - debug (bool, optional): Whether to print debug information. Default is False.
4163
4164        Returns:
4165        - Optional[Backup]: A Backup object containing the path to the created archive
4166                            and its SHA1 hash on success, None on failure.
4167        """
4168        try:
4169            os.makedirs(output_directory, exist_ok=True)
4170            now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
4171
4172            # Create a temporary tar archive in memory to calculate the hash
4173            tar_buffer = io.BytesIO()
4174            with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
4175                tar.add(folder_path, arcname=os.path.basename(folder_path))
4176            tar_buffer.seek(0)
4177            folder_hash = hashlib.sha1(tar_buffer.read()).hexdigest()
4178            output_filename = f"zakatdb_v{self.Version()}_{now}_{folder_hash}.tar.lzma"
4179            output_path = os.path.join(output_directory, output_filename)
4180
4181            # Compress the folder to the final .tar.lzma file
4182            with lzma.open(output_path, "wb") as lzma_file:
4183                tar_buffer.seek(0)  # Reset the buffer
4184                with tarfile.open(fileobj=lzma_file, mode="w") as tar:
4185                    tar.add(folder_path, arcname=os.path.basename(folder_path))
4186
4187            if debug:
4188                print(f"Folder '{folder_path}' has been compressed to '{output_path}'")
4189            return Backup(
4190                path=output_path,
4191                hash=folder_hash,
4192            )
4193        except Exception as e:
4194            print(f"Error during compression: {e}")
4195            return None
4196
4197    def restore(self, tar_lzma_path: str, output_folder_path: str = "uncompressed", debug: bool = False) -> bool:
4198        """
4199        Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash.
4200
4201        The SHA1 hash is extracted from the archive's filename, which must follow
4202        the format: 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'.
4203        This format is essential for successful restoration.
4204
4205        Parameters:
4206        - tar_lzma_path (str): The path to the .tar.lzma file.
4207        - output_folder_path (str, optional): The directory to extract the contents to.
4208                                            Defaults to "uncompressed".
4209        - debug (bool, optional): Whether to print debug information. Default is False.
4210        
4211        Returns:
4212        - bool: True if the restoration was successful and the hash matches, False otherwise.
4213        """
4214        try:
4215            output_folder_path = pathlib.Path(output_folder_path).resolve()
4216            os.makedirs(output_folder_path, exist_ok=True)
4217            filename = os.path.basename(tar_lzma_path)
4218            match = re.match(r"zakatdb_v([^_]+)_(\d{8}_\d{6})_([a-f0-9]{40})\.tar\.lzma", filename)
4219            if not match:
4220                if debug:
4221                    print(f"Error: Invalid filename format: '{filename}'")
4222                return False
4223
4224            expected_hash_from_filename = match.group(3)
4225
4226            with lzma.open(tar_lzma_path, "rb") as lzma_file:
4227                tar_buffer = io.BytesIO(lzma_file.read())  # Read the entire decompressed tar into memory
4228                with tarfile.open(fileobj=tar_buffer, mode="r") as tar:
4229                    tar.extractall(output_folder_path)
4230                    tar_buffer.seek(0)  # Reset buffer to calculate hash
4231                    extracted_hash = hashlib.sha1(tar_buffer.read()).hexdigest()
4232
4233            new_path = os.path.join(output_folder_path, get_first_directory_inside(output_folder_path))
4234            assert os.path.exists(os.path.join(new_path, f"db.{self.ext()}")), f"Restored db.{self.ext()} not found."
4235            if extracted_hash == expected_hash_from_filename:
4236                if debug:
4237                    print(f"'{filename}' has been successfully uncompressed to '{output_folder_path}' and hash verified from filename.")
4238                now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
4239                old_path = os.path.dirname(self.path())
4240                tmp_path = os.path.join(os.path.dirname(old_path), "tmp_restore", now)
4241                if debug:
4242                    print('[xxx] - old_path:', old_path)
4243                    print('[xxx] - tmp_path:', tmp_path)
4244                    print('[xxx] - new_path:', new_path)
4245                try:
4246                    shutil.move(old_path, tmp_path)
4247                    shutil.move(new_path, old_path)
4248                    assert self.load()
4249                    shutil.rmtree(tmp_path)
4250                    return True
4251                except Exception as e:
4252                    print(f"Error applying the restored files: {e}")
4253                    shutil.move(tmp_path, old_path)
4254                    return False
4255            else:
4256                if debug:
4257                    print(f"Warning: Hash mismatch after uncompressing '{filename}'. Expected from filename: {expected_hash_from_filename}, Got: {extracted_hash}")
4258                # Optionally remove the extracted folder if the hash doesn't match
4259                # shutil.rmtree(output_folder_path, ignore_errors=True)
4260                return False
4261
4262        except Exception as e:
4263            print(f"Error during uncompression or hash check: {e}")
4264            return False
4265
4266    def _test_core(self, restore: bool = False, debug: bool = False):
4267
4268        random.seed(1234567890)
4269
4270        # sanity check - core
4271
4272        assert sorted([6, 0, 9, 3], reverse=False) == [0, 3, 6, 9]
4273        assert sorted([6, 0, 9, 3], reverse=True) == [9, 6, 3, 0]
4274        assert sorted(
4275            {6: '6', 0: '0', 9: '9', 3: '3'}.items(),
4276            reverse=False,
4277        ) == [(0, '0'), (3, '3'), (6, '6'), (9, '9')]
4278        assert sorted(
4279            {6: '6', 0: '0', 9: '9', 3: '3'}.items(),
4280            reverse=True,
4281        ) == [(9, '9'), (6, '6'), (3, '3'), (0, '0')]
4282        assert sorted(
4283            {'6': 6, '0': 0, '9': 9, '3': 3}.items(),
4284            reverse=False,
4285        ) == [('0', 0), ('3', 3), ('6', 6), ('9', 9)]
4286        assert sorted(
4287            {'6': 6, '0': 0, '9': 9, '3': 3}.items(),
4288            reverse=True,
4289        ) == [('9', 9), ('6', 6), ('3', 3), ('0', 0)]
4290
4291        Timestamp.test()
4292        AccountID.test(debug)
4293        Time.test(debug)
4294
4295        # test to prevents setting non-existent attributes
4296
4297        for cls in [
4298            StrictDataclass,
4299            BoxZakat,
4300            Box,
4301            Log,
4302            Account,
4303            Exchange,
4304            History,
4305            BoxPlan,
4306            ZakatSummary,
4307            ZakatReport,
4308            Vault,
4309            AccountPaymentPart,
4310            PaymentParts,
4311            SubtractAge,
4312            SubtractAges,
4313            SubtractReport,
4314            TransferTime,
4315            TransferTimes,
4316            TransferRecord,
4317            ImportStatistics,
4318            CSVRecord,
4319            ImportReport,
4320            SizeInfo,
4321            FileInfo,
4322            FileStats,
4323            TimeSummary,
4324            Transaction,
4325            DailyRecords,
4326            Timeline,
4327        ]:
4328            failed = False
4329            try:
4330                x = cls()
4331                x.x = 123
4332            except:
4333                failed = True
4334            assert failed
4335
4336        # sanity check - random forward time
4337
4338        xlist = []
4339        limit = 1000
4340        for _ in range(limit):
4341            y = Time.time()
4342            z = '-'
4343            if y not in xlist:
4344                xlist.append(y)
4345            else:
4346                z = 'x'
4347            if debug:
4348                print(z, y)
4349        xx = len(xlist)
4350        if debug:
4351            print('count', xx, ' - unique: ', (xx / limit) * 100, '%')
4352        assert limit == xx
4353
4354        # test ZakatTracker.split_at_last_symbol
4355
4356        test_cases = [
4357            ("This is a string @ with a symbol.", '@', ("This is a string ", " with a symbol.")),
4358            ("No symbol here.", '$', ("No symbol here.", "")),
4359            ("Multiple $ symbols $ in the string.", '$', ("Multiple $ symbols ", " in the string.")),
4360            ("Here is a symbol%", '%', ("Here is a symbol", "")),
4361            ("@only a symbol", '@', ("", "only a symbol")),
4362            ("", '#', ("", "")),
4363            ("test/test/test.txt", '/', ("test/test", "test.txt")),
4364            ("abc#def#ghi", "#", ("abc#def", "ghi")),
4365            ("abc", "#", ("abc", "")),
4366            ("//https://test", '//', ("//https:", "test")),
4367        ]
4368        
4369        for data, symbol, expected in test_cases:
4370            result = ZakatTracker.split_at_last_symbol(data, symbol)
4371            assert result == expected, f"Test failed for data='{data}', symbol='{symbol}'. Expected {expected}, got {result}"
4372
4373        # human_readable_size
4374
4375        assert ZakatTracker.human_readable_size(0) == '0.00 B'
4376        assert ZakatTracker.human_readable_size(512) == '512.00 B'
4377        assert ZakatTracker.human_readable_size(1023) == '1023.00 B'
4378
4379        assert ZakatTracker.human_readable_size(1024) == '1.00 KB'
4380        assert ZakatTracker.human_readable_size(2048) == '2.00 KB'
4381        assert ZakatTracker.human_readable_size(5120) == '5.00 KB'
4382
4383        assert ZakatTracker.human_readable_size(1024 ** 2) == '1.00 MB'
4384        assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2) == '2.50 MB'
4385
4386        assert ZakatTracker.human_readable_size(1024 ** 3) == '1.00 GB'
4387        assert ZakatTracker.human_readable_size(1024 ** 4) == '1.00 TB'
4388        assert ZakatTracker.human_readable_size(1024 ** 5) == '1.00 PB'
4389
4390        assert ZakatTracker.human_readable_size(1536, decimal_places=0) == '2 KB'
4391        assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2, decimal_places=1) == '2.5 MB'
4392        assert ZakatTracker.human_readable_size(1234567890, decimal_places=3) == '1.150 GB'
4393
4394        try:
4395            # noinspection PyTypeChecker
4396            ZakatTracker.human_readable_size('not a number')
4397            assert False, 'Expected TypeError for invalid input'
4398        except TypeError:
4399            pass
4400
4401        try:
4402            # noinspection PyTypeChecker
4403            ZakatTracker.human_readable_size(1024, decimal_places='not an int')
4404            assert False, 'Expected TypeError for invalid decimal_places'
4405        except TypeError:
4406            pass
4407
4408        # get_dict_size
4409        assert ZakatTracker.get_dict_size({}) == sys.getsizeof({}), 'Empty dictionary size mismatch'
4410        assert ZakatTracker.get_dict_size({'a': 1, 'b': 2.5, 'c': True}) != sys.getsizeof({}), 'Not Empty dictionary'
4411
4412        # number scale
4413        error = 0
4414        total = 0
4415        for sign in ['', '-']:
4416            for max_i, max_j, decimal_places in [
4417                (101, 101, 2),  # fiat currency minimum unit took 2 decimal places
4418                (1, 1_000, 8),  # cryptocurrency like Satoshi in Bitcoin took 8 decimal places
4419                (1, 1_000, 18)  # cryptocurrency like Wei in Ethereum took 18 decimal places
4420            ]:
4421                for return_type in (
4422                        float,
4423                        decimal.Decimal,
4424                ):
4425                    for i in range(max_i):
4426                        for j in range(max_j):
4427                            total += 1
4428                            num_str = f'{sign}{i}.{j:0{decimal_places}d}'
4429                            num = return_type(num_str)
4430                            scaled = self.scale(num, decimal_places=decimal_places)
4431                            unscaled = self.unscale(scaled, return_type=return_type, decimal_places=decimal_places)
4432                            if debug:
4433                                print(
4434                                    f'return_type: {return_type}, num_str: {num_str} - num: {num} - scaled: {scaled} - unscaled: {unscaled}')
4435                            if unscaled != num:
4436                                if debug:
4437                                    print('***** SCALE ERROR *****')
4438                                error += 1
4439        if debug:
4440            print(f'total: {total}, error({error}): {100 * error / total}%')
4441        assert error == 0
4442
4443        # test lock
4444
4445        assert self.nolock()
4446        assert self.__history() is True
4447        lock = self.lock()
4448        assert lock is not None
4449        assert lock > 0
4450        failed = False
4451        try:
4452            self.lock()
4453        except:
4454            failed = True
4455        assert failed
4456        assert self.free(lock)
4457        assert not self.free(lock)
4458
4459        wallet_account_id = self.create_account('wallet')
4460
4461        table = {
4462            AccountID('1'): [
4463                (0, 10, 1000, 1000, 1000, 1, 1),
4464                (0, 20, 3000, 3000, 3000, 2, 2),
4465                (0, 30, 6000, 6000, 6000, 3, 3),
4466                (1, 15, 4500, 4500, 4500, 3, 4),
4467                (1, 50, -500, -500, -500, 4, 5),
4468                (1, 100, -10500, -10500, -10500, 5, 6),
4469            ],
4470            wallet_account_id: [
4471                (1, 90, -9000, -9000, -9000, 1, 1),
4472                (0, 100, 1000, 1000, 1000, 2, 2),
4473                (1, 190, -18000, -18000, -18000, 3, 3),
4474                (0, 1000, 82000, 82000, 82000, 4, 4),
4475            ],
4476        }
4477        for x in table:
4478            for y in table[x]:
4479                lock = self.lock()
4480                if y[0] == 0:
4481                    ref = self.track(
4482                        unscaled_value=y[1],
4483                        desc='test-add',
4484                        account=x,
4485                        created_time_ns=Time.time(),
4486                        debug=debug,
4487                    )
4488                else:
4489                    report = self.subtract(
4490                        unscaled_value=y[1],
4491                        desc='test-sub',
4492                        account=x,
4493                        created_time_ns=Time.time(),
4494                    )
4495                    ref = report.log_ref
4496                    if debug:
4497                        print('_sub', z, Time.time())
4498                assert ref != 0
4499                assert len(self.__vault.account[x].log[ref].file) == 0
4500                for i in range(3):
4501                    file_ref = self.add_file(x, ref, 'file_' + str(i))
4502                    assert file_ref != 0
4503                    if debug:
4504                        print('ref', ref, 'file', file_ref)
4505                    assert len(self.__vault.account[x].log[ref].file) == i + 1
4506                    assert file_ref in self.__vault.account[x].log[ref].file
4507                file_ref = self.add_file(x, ref, 'file_' + str(3))
4508                assert self.remove_file(x, ref, file_ref)
4509                timeline = self.timeline(debug=debug)
4510                if debug:
4511                    print('timeline', timeline)
4512                assert timeline.daily
4513                assert timeline.weekly
4514                assert timeline.monthly
4515                assert timeline.yearly
4516                z = self.balance(x)
4517                if debug:
4518                    print('debug-0', z, y)
4519                assert z == y[2]
4520                z = self.balance(x, False)
4521                if debug:
4522                    print('debug-1', z, y[3])
4523                assert z == y[3]
4524                o = self.__vault.account[x].log
4525                z = 0
4526                for i in o:
4527                    z += o[i].value
4528                if debug:
4529                    print('debug-2', z, type(z))
4530                    print('debug-2', y[4], type(y[4]))
4531                assert z == y[4]
4532                if debug:
4533                    print('debug-2 - PASSED')
4534                assert self.box_size(x) == y[5]
4535                assert self.log_size(x) == y[6]
4536                assert not self.nolock()
4537                assert lock is not None
4538                self.free(lock)
4539                assert self.nolock()
4540            assert self.boxes(x) != {}
4541            assert self.logs(x) != {}
4542
4543            assert not self.hide(x)
4544            assert self.hide(x, False) is False
4545            assert self.hide(x) is False
4546            assert self.hide(x, True)
4547            assert self.hide(x)
4548
4549            assert self.zakatable(x)
4550            assert self.zakatable(x, False) is False
4551            assert self.zakatable(x) is False
4552            assert self.zakatable(x, True)
4553            assert self.zakatable(x)
4554
4555        if restore is True:
4556            # invalid restore point
4557            for lock in [0, time.time_ns(), Time.time()]:
4558                failed = False
4559                try:
4560                    self.recall(dry=True, lock=lock)
4561                except:
4562                    failed = True
4563                assert failed
4564            count = len(self.__vault.history)
4565            if debug:
4566                print('history-count', count)
4567            assert count == 12
4568            # try mode
4569            for _ in range(count):
4570                assert self.recall(dry=True, debug=debug)
4571            count = len(self.__vault.history)
4572            if debug:
4573                print('history-count', count)
4574            assert count == 12
4575            _accounts = list(table.keys())
4576            accounts_limit = len(_accounts) + 1
4577            for i in range(-1, -accounts_limit, -1):
4578                account = _accounts[i]
4579                if debug:
4580                    print(account, len(table[account]))
4581                transaction_limit = len(table[account]) + 1
4582                for j in range(-1, -transaction_limit, -1):
4583                    row = table[account][j]
4584                    if debug:
4585                        print(row, self.balance(account), self.balance(account, False))
4586                    assert self.balance(account) == self.balance(account, False)
4587                    assert self.balance(account) == row[2]
4588                    assert self.recall(dry=False, debug=debug)
4589            assert self.recall(dry=False, debug=debug)
4590            assert self.recall(dry=False, debug=debug)
4591            assert not self.recall(dry=False, debug=debug)
4592            count = len(self.__vault.history)
4593            if debug:
4594                print('history-count', count)
4595            assert count == 0
4596            self.reset()
4597
4598    def _test_storage(self, account_id: Optional[AccountID] = None, debug: bool = False):
4599        old_vault = dataclasses.replace(self.__vault)
4600        old_vault_deep = copy.deepcopy(self.__vault)
4601        old_vault_dict = dataclasses.asdict(self.__vault)
4602        _path = self.path(f'./zakat_test_db/test.{self.ext()}')
4603        if os.path.exists(_path):
4604            os.remove(_path)
4605        for hashed in [False, True]:
4606            self.save(hash_required=hashed)
4607            assert os.path.getsize(_path) > 0
4608            self.reset()
4609            assert self.recall(dry=False, debug=debug) is False
4610            for hash_required in [False, True]:
4611                if debug:
4612                    print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
4613                self.load(hash_required=hashed and hash_required)
4614                if debug:
4615                    print('[debug]', type(self.__vault))
4616                assert self.__vault.account is not None
4617                assert old_vault == self.__vault
4618                assert old_vault_deep == self.__vault
4619                assert old_vault_dict == dataclasses.asdict(self.__vault)
4620                if account_id is not None:
4621                    # corrupt the data
4622                    log_ref = None
4623                    tmp_file_ref = Time.time()
4624                    for k in self.__vault.account[account_id].log:
4625                        log_ref = k
4626                        self.__vault.account[account_id].log[k].file[tmp_file_ref] = 'HACKED'
4627                        break
4628                    assert old_vault != self.__vault
4629                    assert old_vault_deep != self.__vault
4630                    assert old_vault_dict != dataclasses.asdict(self.__vault)
4631                    # fix the data
4632                    del self.__vault.account[account_id].log[log_ref].file[tmp_file_ref]
4633                    assert old_vault == self.__vault
4634                    assert old_vault_deep == self.__vault
4635                    assert old_vault_dict == dataclasses.asdict(self.__vault)
4636            if hashed:
4637                continue
4638            failed = False
4639            try:
4640                hash_required = True
4641                if debug:
4642                    print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
4643                self.load(hash_required=True)
4644            except:
4645                failed = True
4646            assert failed
4647
4648        compressed_dir = "test_compressed"
4649        extracted_dir = "test_extracted"
4650
4651        old_vault = dataclasses.replace(self.__vault)
4652        old_vault_deep = copy.deepcopy(self.__vault)
4653        old_vault_dict = dataclasses.asdict(self.__vault)
4654
4655        # Test backup
4656        backup = self.backup(os.path.dirname(self.path()), compressed_dir, debug=debug)
4657        assert backup.path is not None, "Backup should create a file."
4658        assert backup.hash is not None, "Backup should return a hash."
4659        assert os.path.exists(backup.path), f"Backup file not found at {backup.path}"
4660        assert backup.path.startswith(os.path.join(compressed_dir, f"zakatdb_v{self.Version()}")), "Backup filename should start with zakatdb_v<version>"
4661        assert backup.hash in backup.path, "Backup filename should contain the hash."
4662
4663        # Test restore
4664        restore_successful = self.restore(backup.path, extracted_dir, debug=debug)
4665        assert restore_successful, "Restore should be successful when hash matches."
4666
4667        assert old_vault == self.__vault
4668        assert old_vault_deep == self.__vault
4669        assert old_vault_dict == dataclasses.asdict(self.__vault)
4670
4671        # Test restore with incorrect filename format
4672        invalid_backup_path = os.path.join(compressed_dir, "invalid_name.tar.lzma")
4673        with open(invalid_backup_path, "w") as f:
4674            f.write("")  # Create an empty file
4675        restore_failed_format = self.restore(invalid_backup_path, "temp_extract", debug=debug)
4676        assert not restore_failed_format, "Restore should fail with incorrect filename format."
4677        if os.path.exists("temp_extract"):
4678            shutil.rmtree("temp_extract")
4679
4680        assert old_vault == self.__vault
4681        assert old_vault_deep == self.__vault
4682        assert old_vault_dict == dataclasses.asdict(self.__vault)
4683
4684        # Clean up test files and directories
4685        if not debug:
4686            if os.path.exists(compressed_dir):
4687                shutil.rmtree(compressed_dir)
4688            if os.path.exists(extracted_dir):
4689                shutil.rmtree(extracted_dir)
4690
4691    def test(self, debug: bool = False) -> bool:
4692        if debug:
4693            print('test', f'debug={debug}')
4694        try:
4695
4696            self._test_core(True, debug)
4697            self._test_core(False, debug)
4698
4699            # test_names
4700            self.reset()
4701            x = "test_names"
4702            failed = False
4703            try:
4704                assert self.name(x) == ''
4705            except:
4706                failed = True
4707            assert failed
4708            assert self.names() == {}
4709            failed = False
4710            try:
4711                assert self.name(x, 'qwe') == ''
4712            except:
4713                failed = True
4714            assert failed
4715            account_id0 = self.create_account(x)
4716            assert isinstance(account_id0, AccountID)
4717            assert int(account_id0) > 0
4718            assert self.name(account_id0) == x
4719            assert self.name(account_id0, 'qwe') == 'qwe'
4720            if debug:
4721                print(self.names(keyword='qwe'))
4722            assert self.names(keyword='asd') == {}
4723            assert self.names(keyword='qwe') == {'qwe': account_id0}
4724
4725            # test_create_account
4726            account_name = "test_account"
4727            assert self.names(keyword=account_name) == {}
4728            account_id = self.create_account(account_name)
4729            assert isinstance(account_id, AccountID)
4730            assert int(account_id) > 0
4731            assert account_id in self.__vault.account
4732            assert self.name(account_id) == account_name
4733            assert self.names(keyword=account_name) == {account_name: account_id}
4734
4735            failed = False
4736            try:
4737                self.create_account(account_name)
4738            except:
4739                failed = True
4740            assert failed
4741
4742            # bad are names is forbidden
4743
4744            for bad_name in [
4745                None,
4746                '',
4747                Time.time(),
4748                -Time.time(),
4749                f'{Time.time()}',
4750                f'{-Time.time()}',
4751                0.0,
4752                '0.0',
4753                ' ',
4754            ]:
4755                failed = False
4756                try:
4757                    self.create_account(bad_name)
4758                except:
4759                    failed = True
4760                assert failed
4761
4762            # rename account
4763            assert self.name(account_id) == account_name
4764            assert self.name(account_id, 'asd') == 'asd'
4765            assert self.name(account_id) == 'asd'
4766            # use old and not used name
4767            account_id2 = self.create_account(account_name)
4768            assert int(account_id2) > 0
4769            assert account_id != account_id2
4770            assert self.name(account_id2) == account_name
4771            assert self.names(keyword=account_name) == {account_name: account_id2}
4772
4773            assert self.__history()
4774            count = len(self.__vault.history)
4775            if debug:
4776                print('history-count', count)
4777            assert count == 8
4778
4779            assert self.recall(dry=False, debug=debug)
4780            assert self.name(account_id2) == ''
4781            assert self.account_exists(account_id2)
4782            assert self.recall(dry=False, debug=debug)
4783            assert not self.account_exists(account_id2)
4784            assert self.recall(dry=False, debug=debug)
4785            assert self.name(account_id) == account_name
4786            assert self.recall(dry=False, debug=debug)
4787            assert self.account_exists(account_id)
4788            assert self.recall(dry=False, debug=debug)
4789            assert not self.account_exists(account_id)
4790            assert self.names(keyword='qwe') == {'qwe': account_id0}
4791            assert self.recall(dry=False, debug=debug)
4792            assert self.names(keyword='qwe') == {}
4793            assert self.name(account_id0) == x
4794            assert self.recall(dry=False, debug=debug)
4795            assert self.name(account_id0) == ''
4796            assert self.account_exists(account_id0)
4797            assert self.recall(dry=False, debug=debug)
4798            assert not self.account_exists(account_id0)
4799            assert not self.recall(dry=False, debug=debug)
4800
4801            # Not allowed for duplicate transactions in the same account and time
4802
4803            created = Time.time()
4804            same_account_id = self.create_account('same')
4805            self.track(100, 'test-1', same_account_id, True, created)
4806            failed = False
4807            try:
4808                self.track(50, 'test-1', same_account_id, True, created)
4809            except:
4810                failed = True
4811            assert failed is True
4812
4813            self.reset()
4814
4815            # Same account transfer
4816            for x in [1, 'a', True, 1.8, None]:
4817                failed = False
4818                try:
4819                    self.transfer(1, x, x, 'same-account', debug=debug)
4820                except:
4821                    failed = True
4822                assert failed is True
4823
4824            # Always preserve box age during transfer
4825
4826            series: list[tuple[int, int]] = [
4827                (30, 4),
4828                (60, 3),
4829                (90, 2),
4830            ]
4831            case = {
4832                3000: {
4833                    'series': series,
4834                    'rest': 15000,
4835                },
4836                6000: {
4837                    'series': series,
4838                    'rest': 12000,
4839                },
4840                9000: {
4841                    'series': series,
4842                    'rest': 9000,
4843                },
4844                18000: {
4845                    'series': series,
4846                    'rest': 0,
4847                },
4848                27000: {
4849                    'series': series,
4850                    'rest': -9000,
4851                },
4852                36000: {
4853                    'series': series,
4854                    'rest': -18000,
4855                },
4856            }
4857
4858            selected_time = Time.time() - ZakatTracker.TimeCycle()
4859            ages_account_id = self.create_account('ages')
4860            future_account_id = self.create_account('future')
4861
4862            for total in case:
4863                if debug:
4864                    print('--------------------------------------------------------')
4865                    print(f'case[{total}]', case[total])
4866                for x in case[total]['series']:
4867                    self.track(
4868                        unscaled_value=x[0],
4869                        desc=f'test-{x} ages',
4870                        account=ages_account_id,
4871                        created_time_ns=selected_time * x[1],
4872                    )
4873
4874                unscaled_total = self.unscale(total)
4875                if debug:
4876                    print('unscaled_total', unscaled_total)
4877                refs = self.transfer(
4878                    unscaled_amount=unscaled_total,
4879                    from_account=ages_account_id,
4880                    to_account=future_account_id,
4881                    desc='Zakat Movement',
4882                    debug=debug,
4883                )
4884
4885                if debug:
4886                    print('refs', refs)
4887
4888                ages_cache_balance = self.balance(ages_account_id)
4889                ages_fresh_balance = self.balance(ages_account_id, False)
4890                rest = case[total]['rest']
4891                if debug:
4892                    print('source', ages_cache_balance, ages_fresh_balance, rest)
4893                assert ages_cache_balance == rest
4894                assert ages_fresh_balance == rest
4895
4896                future_cache_balance = self.balance(future_account_id)
4897                future_fresh_balance = self.balance(future_account_id, False)
4898                if debug:
4899                    print('target', future_cache_balance, future_fresh_balance, total)
4900                    print('refs', refs)
4901                assert future_cache_balance == total
4902                assert future_fresh_balance == total
4903
4904                # TODO: check boxes times for `ages` should equal box times in `future`
4905                for ref in self.__vault.account[ages_account_id].box:
4906                    ages_capital = self.__vault.account[ages_account_id].box[ref].capital
4907                    ages_rest = self.__vault.account[ages_account_id].box[ref].rest
4908                    future_capital = 0
4909                    if ref in self.__vault.account[future_account_id].box:
4910                        future_capital = self.__vault.account[future_account_id].box[ref].capital
4911                    future_rest = 0
4912                    if ref in self.__vault.account[future_account_id].box:
4913                        future_rest = self.__vault.account[future_account_id].box[ref].rest
4914                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
4915                        if debug:
4916                            print('================================================================')
4917                            print('ages', ages_capital, ages_rest)
4918                            print('future', future_capital, future_rest)
4919                        if ages_rest == 0:
4920                            assert ages_capital == future_capital
4921                        elif ages_rest < 0:
4922                            assert -ages_capital == future_capital
4923                        elif ages_rest > 0:
4924                            assert ages_capital == ages_rest + future_capital
4925                self.reset()
4926                assert len(self.__vault.history) == 0
4927
4928            assert self.__history()
4929            assert self.__history(False) is False
4930            assert self.__history() is False
4931            assert self.__history(True)
4932            assert self.__history()
4933            if debug:
4934                print('####################################################################')
4935
4936            wallet_account_id = self.create_account('wallet')
4937            safe_account_id = self.create_account('safe')
4938            bank_account_id = self.create_account('bank')
4939            transaction = [
4940                (
4941                    20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1,
4942                    2000, 2000, 2000, 1, 1,
4943                ),
4944                (
4945                    750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2,
4946                    75000, 75000, 75000, 1, 1,
4947                ),
4948                (
4949                    600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2,
4950                    60000, 60000, 60000, 1, 1,
4951                ),
4952            ]
4953            for z in transaction:
4954                lock = self.lock()
4955                x = z[1]
4956                y = z[2]
4957                self.transfer(
4958                    unscaled_amount=z[0],
4959                    from_account=x,
4960                    to_account=y,
4961                    desc='test-transfer',
4962                    debug=debug,
4963                )
4964                zz = self.balance(x)
4965                if debug:
4966                    print(zz, z)
4967                assert zz == z[3]
4968                xx = self.accounts()[x]
4969                assert xx.balance == z[3]
4970                assert self.balance(x, False) == z[4]
4971                assert xx.balance == z[4]
4972
4973                s = 0
4974                log = self.__vault.account[x].log
4975                for i in log:
4976                    s += log[i].value
4977                if debug:
4978                    print('s', s, 'z[5]', z[5])
4979                assert s == z[5]
4980
4981                assert self.box_size(x) == z[6]
4982                assert self.log_size(x) == z[7]
4983
4984                yy = self.accounts()[y]
4985                assert self.balance(y) == z[8]
4986                assert yy.balance == z[8]
4987                assert self.balance(y, False) == z[9]
4988                assert yy.balance == z[9]
4989
4990                s = 0
4991                log = self.__vault.account[y].log
4992                for i in log:
4993                    s += log[i].value
4994                assert s == z[10]
4995
4996                assert self.box_size(y) == z[11]
4997                assert self.log_size(y) == z[12]
4998                assert lock is not None
4999                assert self.free(lock)
5000
5001            assert self.nolock()
5002            history_count = len(self.__vault.history)
5003            transaction_count = len(transaction)
5004            if debug:
5005                print('history-count', history_count, transaction_count)
5006            assert history_count == transaction_count * 3
5007            assert not self.free(Time.time())
5008            assert self.free(self.lock())
5009            assert self.nolock()
5010            assert len(self.__vault.history) == transaction_count * 3
5011
5012            # recall
5013
5014            assert self.nolock()
5015            for i in range(transaction_count * 3, 0, -1):
5016                assert len(self.__vault.history) == i
5017                assert self.recall(dry=False, debug=debug) is True
5018            assert len(self.__vault.history) == 0
5019            assert self.recall(dry=False, debug=debug) is False
5020            assert len(self.__vault.history) == 0
5021
5022            # exchange
5023
5024            cash_account_id = self.create_account('cash')
5025            self.exchange(cash_account_id, 25, 3.75, '2024-06-25')
5026            self.exchange(cash_account_id, 22, 3.73, '2024-06-22')
5027            self.exchange(cash_account_id, 15, 3.69, '2024-06-15')
5028            self.exchange(cash_account_id, 10, 3.66)
5029
5030            assert self.nolock()
5031
5032            bank_account_id = self.create_account('bank')
5033            for i in range(1, 30):
5034                exchange = self.exchange(cash_account_id, i)
5035                rate, description, created = exchange.rate, exchange.description, exchange.time
5036                if debug:
5037                    print(i, rate, description, created)
5038                assert created
5039                if i < 10:
5040                    assert rate == 1
5041                    assert description is None
5042                elif i == 10:
5043                    assert rate == 3.66
5044                    assert description is None
5045                elif i < 15:
5046                    assert rate == 3.66
5047                    assert description is None
5048                elif i == 15:
5049                    assert rate == 3.69
5050                    assert description is not None
5051                elif i < 22:
5052                    assert rate == 3.69
5053                    assert description is not None
5054                elif i == 22:
5055                    assert rate == 3.73
5056                    assert description is not None
5057                elif i >= 25:
5058                    assert rate == 3.75
5059                    assert description is not None
5060                exchange = self.exchange(bank_account_id, i)
5061                rate, description, created = exchange.rate, exchange.description, exchange.time
5062                if debug:
5063                    print(i, rate, description, created)
5064                assert created
5065                assert rate == 1
5066                assert description is None
5067
5068            assert len(self.__vault.exchange) == 1
5069            assert len(self.exchanges()) == 1
5070            self.__vault.exchange.clear()
5071            assert len(self.__vault.exchange) == 0
5072            assert len(self.exchanges()) == 0
5073            self.reset()
5074
5075            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
5076            cash_account_id = self.create_account('cash')
5077            self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25')
5078            self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22')
5079            self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15')
5080            self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66)
5081
5082            assert self.nolock()
5083
5084            test_account_id = self.create_account('test')
5085            for i in [x * 0.12 for x in range(-15, 21)]:
5086                if i <= 0:
5087                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange()
5088                else:
5089                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange()
5090
5091            assert self.nolock()
5092
5093           # اختبار النتائج باستخدام التواريخ بالنانو ثانية
5094            bank_account_id = self.create_account('bank')
5095            for i in range(1, 31):
5096                timestamp_ns = ZakatTracker.day_to_time(i)
5097                exchange = self.exchange(cash_account_id, timestamp_ns)
5098                rate, description, created = exchange.rate, exchange.description, exchange.time
5099                if debug:
5100                    print(i, rate, description, created)
5101                assert created
5102                if i < 10:
5103                    assert rate == 1
5104                    assert description is None
5105                elif i == 10:
5106                    assert rate == 3.66
5107                    assert description is None
5108                elif i < 15:
5109                    assert rate == 3.66
5110                    assert description is None
5111                elif i == 15:
5112                    assert rate == 3.69
5113                    assert description is not None
5114                elif i < 22:
5115                    assert rate == 3.69
5116                    assert description is not None
5117                elif i == 22:
5118                    assert rate == 3.73
5119                    assert description is not None
5120                elif i >= 25:
5121                    assert rate == 3.75
5122                    assert description is not None
5123                exchange = self.exchange(bank_account_id, i)
5124                rate, description, created = exchange.rate, exchange.description, exchange.time
5125                if debug:
5126                    print(i, rate, description, created)
5127                assert created
5128                assert rate == 1
5129                assert description is None
5130
5131            assert self.nolock()
5132            if debug:
5133                print(self.__vault.history, len(self.__vault.history))
5134            for _ in range(len(self.__vault.history)):
5135                assert self.recall(dry=False, debug=debug)
5136            assert not self.recall(dry=False, debug=debug)
5137
5138            self.reset()
5139
5140            # test transfer between accounts with different exchange rate
5141
5142            a_SAR = self.create_account('Bank (SAR)')
5143            b_USD = self.create_account('Bank (USD)')
5144            c_SAR = self.create_account('Safe (SAR)')
5145            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
5146            for case in [
5147                (0, a_SAR, 'SAR Gift', 1000, 100000),
5148                (1, a_SAR, 1),
5149                (0, b_USD, 'USD Gift', 500, 50000),
5150                (1, b_USD, 1),
5151                (2, b_USD, 3.75),
5152                (1, b_USD, 3.75),
5153                (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500),
5154                (0, c_SAR, 'Salary', 750, 75000),
5155                (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000),
5156                (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100),
5157            ]:
5158                if debug:
5159                    print('case', case)
5160                match (case[0]):
5161                    case 0:  # track
5162                        _, account, desc, x, balance = case
5163                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
5164
5165                        cached_value = self.balance(account, cached=True)
5166                        fresh_value = self.balance(account, cached=False)
5167                        if debug:
5168                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
5169                        assert cached_value == balance
5170                        assert fresh_value == balance
5171                    case 1:  # check-exchange
5172                        _, account, expected_rate = case
5173                        t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
5174                        if debug:
5175                            print('t-exchange', t_exchange)
5176                        assert t_exchange.rate == expected_rate
5177                    case 2:  # do-exchange
5178                        _, account, rate = case
5179                        self.exchange(account, rate=rate, debug=debug)
5180                        b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
5181                        if debug:
5182                            print('b-exchange', b_exchange)
5183                        assert b_exchange.rate == rate
5184                    case 3:  # transfer
5185                        _, x, a, b, desc, a_balance, b_balance = case
5186                        self.transfer(x, a, b, desc, debug=debug)
5187
5188                        cached_value = self.balance(a, cached=True)
5189                        fresh_value = self.balance(a, cached=False)
5190                        if debug:
5191                            print(
5192                                'account', a,
5193                                'cached_value', cached_value,
5194                                'fresh_value', fresh_value,
5195                                'a_balance', a_balance,
5196                            )
5197                        assert cached_value == a_balance
5198                        assert fresh_value == a_balance
5199
5200                        cached_value = self.balance(b, cached=True)
5201                        fresh_value = self.balance(b, cached=False)
5202                        if debug:
5203                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
5204                        assert cached_value == b_balance
5205                        assert fresh_value == b_balance
5206
5207            # Transfer all in many chunks randomly from B to A
5208            a_SAR_balance = 137125
5209            b_USD_balance = 50100
5210            b_USD_exchange = self.exchange(b_USD)
5211            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
5212            if debug:
5213                print('amounts', amounts)
5214            i = 0
5215            for x in amounts:
5216                if debug:
5217                    print(f'{i} - transfer-with-exchange({x})')
5218                self.transfer(
5219                    unscaled_amount=self.unscale(x),
5220                    from_account=b_USD,
5221                    to_account=a_SAR,
5222                    desc=f'{x} USD -> SAR',
5223                    debug=debug,
5224                )
5225
5226                b_USD_balance -= x
5227                cached_value = self.balance(b_USD, cached=True)
5228                fresh_value = self.balance(b_USD, cached=False)
5229                if debug:
5230                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5231                          b_USD_balance)
5232                assert cached_value == b_USD_balance
5233                assert fresh_value == b_USD_balance
5234
5235                a_SAR_balance += int(x * b_USD_exchange.rate)
5236                cached_value = self.balance(a_SAR, cached=True)
5237                fresh_value = self.balance(a_SAR, cached=False)
5238                if debug:
5239                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5240                          a_SAR_balance, 'rate', b_USD_exchange.rate)
5241                assert cached_value == a_SAR_balance
5242                assert fresh_value == a_SAR_balance
5243                i += 1
5244
5245            # Transfer all in many chunks randomly from C to A
5246            c_SAR_balance = 37500
5247            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
5248            if debug:
5249                print('amounts', amounts)
5250            i = 0
5251            for x in amounts:
5252                if debug:
5253                    print(f'{i} - transfer-with-exchange({x})')
5254                self.transfer(
5255                    unscaled_amount=self.unscale(x),
5256                    from_account=c_SAR,
5257                    to_account=a_SAR,
5258                    desc=f'{x} SAR -> a_SAR',
5259                    debug=debug,
5260                )
5261
5262                c_SAR_balance -= x
5263                cached_value = self.balance(c_SAR, cached=True)
5264                fresh_value = self.balance(c_SAR, cached=False)
5265                if debug:
5266                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5267                          c_SAR_balance)
5268                assert cached_value == c_SAR_balance
5269                assert fresh_value == c_SAR_balance
5270
5271                a_SAR_balance += x
5272                cached_value = self.balance(a_SAR, cached=True)
5273                fresh_value = self.balance(a_SAR, cached=False)
5274                if debug:
5275                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5276                          a_SAR_balance)
5277                assert cached_value == a_SAR_balance
5278                assert fresh_value == a_SAR_balance
5279                i += 1
5280
5281            assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}')
5282
5283            # check & zakat with exchange rates for many cycles
5284
5285            lock = None
5286            safe_account_id = self.create_account('safe')
5287            cave_account_id = self.create_account('cave')
5288            for rate, values in {
5289                1: {
5290                    'in': [1000, 2000, 10000],
5291                    'exchanged': [100000, 200000, 1000000],
5292                    'out': [2500, 5000, 73140],
5293                },
5294                3.75: {
5295                    'in': [200, 1000, 5000],
5296                    'exchanged': [75000, 375000, 1875000],
5297                    'out': [1875, 9375, 137138],
5298                },
5299            }.items():
5300                a, b, c = values['in']
5301                m, n, o = values['exchanged']
5302                x, y, z = values['out']
5303                if debug:
5304                    print('rate', rate, 'values', values)
5305                for case in [
5306                    (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5307                        {safe_account_id: {0: {'below_nisab': x}}},
5308                    ], False, m),
5309                    (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5310                        {safe_account_id: {0: {'count': 1, 'total': y}}},
5311                    ], True, n),
5312                    (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [
5313                        {cave_account_id: {0: {'count': 3, 'total': z}}},
5314                    ], True, o),
5315                ]:
5316                    if debug:
5317                        print(f'############# check(rate: {rate}) #############')
5318                        print('case', case)
5319                    self.reset()
5320                    self.exchange(account=case[1], created_time_ns=case[2], rate=rate)
5321                    self.track(
5322                        unscaled_value=case[0],
5323                        desc='test-check',
5324                        account=case[1],
5325                        created_time_ns=case[2],
5326                    )
5327                    assert self.snapshot()
5328
5329                    # assert self.nolock()
5330                    # history_size = len(self.__vault.history)
5331                    # print('history_size', history_size)
5332                    # assert history_size == 2
5333                    lock = self.lock()
5334                    assert lock
5335                    assert not self.nolock()
5336                    assert self.__vault.cache.zakat is None
5337                    report = self.check(2.17, None, debug)
5338                    if debug:
5339                        print('[report]', report)
5340                    assert case[4] == report.valid
5341                    assert case[5] == report.summary.total_wealth
5342                    assert case[5] == report.summary.total_zakatable_amount
5343                    if report.valid:
5344                        assert self.__vault.cache.zakat is not None
5345                        assert report.plan
5346                        assert self.zakat(report, debug=debug)
5347                        assert self.__vault.cache.zakat is None
5348                        if debug:
5349                            pp().pprint(self.__vault)
5350                        self._test_storage(debug=debug)
5351
5352                        for x in report.plan:
5353                            assert case[1] == x
5354                            if report.plan[x][0].below_nisab:
5355                                if debug:
5356                                    print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab'])
5357                                assert report.plan[x][0].total == case[3][0][x][0]['below_nisab']
5358                            else:
5359                                if debug:
5360                                    print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total'])
5361                                    print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total'])
5362                                    print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count'])
5363                                assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total']
5364                                assert int(report.plan[x][0].total) == case[3][0][x][0]['total']
5365                                assert report.plan[x][0].count == case[3][0][x][0]['count']
5366                    else:
5367                        assert self.__vault.cache.zakat is None
5368                        result = self.zakat(report, debug=debug)
5369                        if debug:
5370                            print('zakat-result', result, case[4])
5371                        assert result == case[4]
5372                        report = self.check(2.17, None, debug)
5373                        assert report.valid is False
5374            self._test_storage(account_id=cave_account_id, debug=debug)
5375
5376            # recall after zakat
5377
5378            history_size = len(self.__vault.history)
5379            if debug:
5380                print('history_size', history_size)
5381            assert history_size == 3
5382            assert not self.nolock()
5383            assert self.recall(dry=False, debug=debug) is False
5384            self.free(lock)
5385            assert self.nolock()
5386
5387            for i in range(3, 0, -1):
5388                history_size = len(self.__vault.history)
5389                if debug:
5390                    print('history_size', history_size)
5391                assert history_size == i
5392                assert self.recall(dry=False, debug=debug) is True
5393
5394            assert self.nolock()
5395            assert self.recall(dry=False, debug=debug) is False
5396
5397            history_size = len(self.__vault.history)
5398            if debug:
5399                print('history_size', history_size)
5400            assert history_size == 0
5401
5402            account_size = len(self.__vault.account)
5403            if debug:
5404                print('account_size', account_size)
5405            assert account_size == 0
5406
5407            report_size = len(self.__vault.report)
5408            if debug:
5409                print('report_size', report_size)
5410            assert report_size == 0
5411
5412            assert self.nolock()
5413
5414            # csv
5415
5416            csv_count = 1000
5417
5418            for with_rate, path in {
5419                False: 'test-import_csv-no-exchange',
5420                True: 'test-import_csv-with-exchange',
5421            }.items():
5422
5423                if debug:
5424                    print('test_import_csv', with_rate, path)
5425
5426                csv_path = path + '.csv'
5427                if os.path.exists(csv_path):
5428                    os.remove(csv_path)
5429                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
5430                if debug:
5431                    print('generate_random_csv_file', c)
5432                assert c == csv_count
5433                assert os.path.getsize(csv_path) > 0
5434                cache_path = self.import_csv_cache_path()
5435                if os.path.exists(cache_path):
5436                    os.remove(cache_path)
5437                self.reset()
5438                lock = self.lock()
5439                import_report = self.import_csv(csv_path, debug=debug)
5440                bad_count = len(import_report.bad)
5441                if debug:
5442                    print(f'csv-imported: {import_report.statistics} = count({csv_count})')
5443                    print('bad', import_report.bad)
5444                assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count
5445                assert import_report.statistics.created == csv_count
5446                assert bad_count == 0
5447                assert bad_count == import_report.statistics.bad
5448                tmp_size = os.path.getsize(cache_path)
5449                assert tmp_size > 0
5450
5451                import_report_2 = self.import_csv(csv_path, debug=debug)
5452                bad_2_count = len(import_report_2.bad)
5453                if debug:
5454                    print(f'csv-imported: {import_report_2}')
5455                    print('bad', import_report_2.bad)
5456                assert tmp_size == os.path.getsize(cache_path)
5457                assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count
5458                assert import_report.statistics.created == import_report_2.statistics.found
5459                assert bad_count == bad_2_count
5460                assert import_report_2.statistics.found == csv_count
5461                assert bad_2_count == 0
5462                assert bad_2_count == import_report_2.statistics.bad
5463                assert import_report_2.statistics.created == 0
5464
5465                # payment parts
5466
5467                positive_parts = self.build_payment_parts(100, positive_only=True)
5468                assert self.check_payment_parts(positive_parts) != 0
5469                assert self.check_payment_parts(positive_parts) != 0
5470                all_parts = self.build_payment_parts(300, positive_only=False)
5471                assert self.check_payment_parts(all_parts) != 0
5472                assert self.check_payment_parts(all_parts) != 0
5473                if debug:
5474                    pp().pprint(positive_parts)
5475                    pp().pprint(all_parts)
5476                # dynamic discount
5477                suite = []
5478                count = 3
5479                for exceed in [False, True]:
5480                    case = []
5481                    for part in [positive_parts, all_parts]:
5482                        #part = parts.copy()
5483                        demand = part.demand
5484                        if debug:
5485                            print(demand, part.total)
5486                        i = 0
5487                        z = demand / count
5488                        cp = PaymentParts(
5489                            demand=demand,
5490                            exceed=exceed,
5491                            total=part.total,
5492                        )
5493                        j = ''
5494                        for x, y in part.account.items():
5495                            x_exchange = self.exchange(x)
5496                            zz = self.exchange_calc(z, 1, x_exchange.rate)
5497                            if exceed and zz <= demand:
5498                                i += 1
5499                                y.part = zz
5500                                if debug:
5501                                    print(exceed, y)
5502                                cp.account[x] = y
5503                                case.append(y)
5504                            elif not exceed and y.balance >= zz:
5505                                i += 1
5506                                y.part = zz
5507                                if debug:
5508                                    print(exceed, y)
5509                                cp.account[x] = y
5510                                case.append(y)
5511                            j = x
5512                            if i >= count:
5513                                break
5514                        if debug:
5515                            print('[debug]', j)
5516                            print('[debug]', cp.account[j])
5517                        if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0):
5518                            suite.append(cp)
5519                if debug:
5520                    print('suite', len(suite))
5521                for case in suite:
5522                    if debug:
5523                        print('case', case)
5524                    result = self.check_payment_parts(case)
5525                    if debug:
5526                        print('check_payment_parts', result, f'exceed: {exceed}')
5527                    assert result == 0
5528
5529                    assert self.__vault.cache.zakat is None
5530                    report = self.check(2.17, None, debug)
5531                    if debug:
5532                        print('valid', report.valid)
5533                    zakat_result = self.zakat(report, parts=case, debug=debug)
5534                    if debug:
5535                        print('zakat-result', zakat_result)
5536                    assert report.valid == zakat_result
5537                    # test verified zakat report is required
5538                    if zakat_result:
5539                        assert self.__vault.cache.zakat is None
5540                        failed = False
5541                        try:
5542                            self.zakat(report, parts=case, debug=debug)
5543                        except:
5544                            failed = True
5545                        assert failed
5546
5547                assert self.free(lock)
5548
5549            assert self.save(path + f'.{self.ext()}')
5550            assert self.save(f'1000-transactions-test.{self.ext()}')
5551            return True
5552        except Exception as e:
5553            if self.__debug_output:
5554                pp().pprint(self.__vault)
5555                print('============================================================================')
5556                pp().pprint(self.__debug_output)
5557            assert self.save(f'test-snapshot.{self.ext()}')
5558            raise e

A class for tracking and calculating Zakat.

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

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

The class uses a json 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, save, load and more. These methods provide additional functionalities and flexibility for interacting with and managing the Zakat tracker.

Attributes:

Data Structure:

The ZakatTracker class utilizes a nested dataclasses structure called '__vault' to store and manage data, here below is just a demonstration:

__vault (dict):
    - account (dict):
        - {account_id} (dict):
            - balance (int): The current balance of the account.
            - name (str): The name of the account.
            - created (int): The creation time for the account.
            - box (dict): A dictionary storing transaction details.
                - {timestamp} (dict):
                    - capital (int): The initial amount of the transaction.
                    - rest (int): The remaining amount after Zakat deductions and withdrawal.
                    - zakat (dict):
                        - count (int): The number of times Zakat has been calculated for this transaction.
                        - last (int): The timestamp of the last Zakat calculation.
                        - 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_id} (dict):
            - {timestamps} (dict):
                - rate (float): Exchange rate when compared to local currency.
                - description (str): The description of the exchange rate.
    - history (dict):
        - {lock_timestamp} (dict): A list of dictionaries storing the history of actions performed.
            - {order_timestamp} (dict):
                - {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 reference 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_db/', history_mode: bool = True)
1488    def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True):
1489        """
1490        Initialize ZakatTracker with database path and history mode.
1491
1492        Parameters:
1493        - db_path (str, optional): The path to the database  directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
1494        - history_mode (bool, optional): The mode for tracking history. Default is True.
1495
1496        Returns:
1497        None
1498        """
1499        self.reset()
1500        self.__memory_mode = db_path == ':memory:'
1501        self.__history(history_mode)
1502        if not self.__memory_mode:
1503            self.path(f'{db_path}/db.{self.ext()}')

Initialize ZakatTracker with database path and history mode.

Parameters:

  • db_path (str, optional): The path to the database directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
  • history_mode (bool, optional): The mode for tracking history. Default is True.

Returns: None

@staticmethod
def Version() -> str:
1398    @staticmethod
1399    def Version() -> str:
1400        """
1401        Returns the current version of the software.
1402
1403        This function returns a string representing the current version of the software,
1404        including major, minor, and patch version numbers in the format 'X.Y.Z'.
1405
1406        Returns:
1407        - str: The current version of the software.
1408        """
1409        version = '0.3.4'
1410        git_hash, unstaged_count, commit_count_since_last_tag = get_git_status()
1411        if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0):
1412            version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}"
1413            print(version)
1414        return version

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:
1416    @staticmethod
1417    def ZakatCut(x: float) -> float:
1418        """
1419        Calculates the Zakat amount due on an asset.
1420
1421        This function calculates the zakat amount due on a given asset value over one lunar year.
1422        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
1423        that exceeds a certain threshold (Nisab).
1424
1425        Parameters:
1426        - x (float): The total value of the asset on which Zakat is to be calculated.
1427
1428        Returns:
1429        - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
1430        """
1431        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 (float): The total value of the asset on which Zakat is to be calculated.

Returns:

  • float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
@staticmethod
def TimeCycle(days: int = 355) -> int:
1433    @staticmethod
1434    def TimeCycle(days: int = 355) -> int:
1435        """
1436        Calculates the approximate duration of a lunar year in nanoseconds.
1437
1438        This function calculates the approximate duration of a lunar year based on the given number of days.
1439        It converts the given number of days into nanoseconds for use in high-precision timing applications.
1440
1441        Parameters:
1442        - days (int, optional): The number of days in a lunar year. Defaults to 355,
1443              which is an approximation of the average length of a lunar year.
1444
1445        Returns:
1446        - int: The approximate duration of a lunar year in nanoseconds.
1447        """
1448        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 (int, optional): The number of days in a lunar year. Defaults to 355, which is an approximation of the average length of a lunar year.

Returns:

  • int: The approximate duration of a lunar year in nanoseconds.
@staticmethod
def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
1450    @staticmethod
1451    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
1452        """
1453        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
1454
1455        This function calculates the Nisab value, which is the minimum threshold of wealth,
1456        that makes an individual liable for paying Zakat.
1457        The Nisab value is determined by the equivalent value of a specific amount
1458        of gold or silver (currently 595 grams in silver) in the local currency.
1459
1460        Parameters:
1461        - gram_price (float): The price per gram of Nisab.
1462        - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver.
1463
1464        Returns:
1465        - float: The total value of Nisab based on the given price per gram.
1466        """
1467        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, optional): 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.
@staticmethod
def ext() -> str:
1469    @staticmethod
1470    def ext() -> str:
1471        """
1472        Returns the file extension used by the ZakatTracker class.
1473
1474        Parameters:
1475        None
1476
1477        Returns:
1478        - str: The file extension used by the ZakatTracker class, which is 'json'.
1479        """
1480        return 'json'

Returns the file extension used by the ZakatTracker class.

Parameters: None

Returns:

  • str: The file extension used by the ZakatTracker class, which is 'json'.
def memory_mode(self) -> bool:
1505    def memory_mode(self) -> bool:
1506        """
1507        Check if the ZakatTracker is operating in memory mode.
1508
1509        Returns:
1510        - bool: True if the database is in memory, False otherwise.
1511        """
1512        return self.__memory_mode

Check if the ZakatTracker is operating in memory mode.

Returns:

  • bool: True if the database is in memory, False otherwise.
def path(self, path: Optional[str] = None) -> str:
1514    def path(self, path: Optional[str] = None) -> str:
1515        """
1516        Set or get the path to the database file.
1517
1518        If no path is provided, the current path is returned.
1519        If a path is provided, it is set as the new path.
1520        The function also creates the necessary directories if the provided path is a file.
1521
1522        Parameters:
1523        - path (str, optional): The new path to the database file. If not provided, the current path is returned.
1524
1525        Returns:
1526        - str: The current or new path to the database file.
1527        """
1528        if path is None:
1529            return str(self.__vault_path)
1530        self.__vault_path = pathlib.Path(path).resolve()
1531        base_path = pathlib.Path(path).resolve()
1532        if base_path.is_file() or base_path.suffix:
1533            base_path = base_path.parent
1534        base_path.mkdir(parents=True, exist_ok=True)
1535        self.__base_path = base_path
1536        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, optional): 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:
1538    def base_path(self, *args) -> str:
1539        """
1540        Generate a base path by joining the provided arguments with the existing base path.
1541
1542        Parameters:
1543        - *args (str): Variable length argument list of strings to be joined with the base path.
1544
1545        Returns:
1546        - str: The generated base path. If no arguments are provided, the existing base path is returned.
1547        """
1548        if not args:
1549            return str(self.__base_path)
1550        filtered_args = []
1551        ignored_filename = None
1552        for arg in args:
1553            if pathlib.Path(arg).suffix:
1554                ignored_filename = arg
1555            else:
1556                filtered_args.append(arg)
1557        base_path = pathlib.Path(self.__base_path)
1558        full_path = base_path.joinpath(*filtered_args)
1559        full_path.mkdir(parents=True, exist_ok=True)
1560        if ignored_filename is not None:
1561            return full_path.resolve() / ignored_filename  # Join with the ignored filename
1562        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:
1564    @staticmethod
1565    def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
1566        """
1567        Scales a numerical value by a specified power of 10, returning an integer.
1568
1569        This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and
1570        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
1571
1572        Parameters:
1573        - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
1574        - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
1575            by a factor of 100 (e.g., converts 1.23 to 123).
1576
1577        Returns:
1578        - The scaled value, rounded to the nearest integer.
1579
1580        Raises:
1581        - TypeError: If the input `x` is not a valid numeric type.
1582
1583        Examples:
1584        ```bash
1585        >>> ZakatTracker.scale(3.14159)
1586        314
1587        >>> ZakatTracker.scale(1234, decimal_places=3)
1588        1234000
1589        >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4)
1590        50
1591        ```
1592        """
1593        if not isinstance(x, (float, int, decimal.Decimal)):
1594            raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.')
1595        return int(decimal.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.Decimal) and facilitate precise scaling operations, particularly useful in financial or scientific calculations.

Parameters:

  • x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
  • decimal_places (int, optional): 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.Decimal('0.005'), decimal_places=4)
50
@staticmethod
def unscale( x: int, return_type: type = <class 'float'>, decimal_places: int = 2) -> float | decimal.Decimal:
1597    @staticmethod
1598    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal:
1599        """
1600        Unscales an integer by a power of 10.
1601
1602        Parameters:
1603        - x (int): The integer to unscale.
1604        - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
1605        - decimal_places (int, optional): The power of 10 to use. Defaults to 2.
1606
1607        Returns:
1608        - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.
1609
1610        Raises:
1611        - TypeError: If the return_type is not float or decimal.Decimal.
1612        """
1613        if return_type not in (float, decimal.Decimal):
1614            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.')
1615        return round(return_type(x / (10 ** decimal_places)), decimal_places)

Unscales an integer by a power of 10.

Parameters:

  • x (int): The integer to unscale.
  • return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
  • decimal_places (int, optional): The power of 10 to use. Defaults to 2.

Returns:

  • float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.

Raises:

  • TypeError: If the return_type is not float or decimal.Decimal.
def reset(self) -> None:
1617    def reset(self) -> None:
1618        """
1619        Reset the internal data structure to its initial state.
1620
1621        Parameters:
1622        None
1623
1624        Returns:
1625        None
1626        """
1627        self.__vault = Vault()

Reset the internal data structure to its initial state.

Parameters: None

Returns: None

def clean_history(self, lock: Optional[Timestamp] = None) -> int:
1629    def clean_history(self, lock: Optional[Timestamp] = None) -> int:
1630        """
1631        Cleans up the empty history records of actions performed on the ZakatTracker instance.
1632
1633        Parameters:
1634        - lock (Timestamp, optional): The lock ID is used to clean up the empty history.
1635            If not provided, it cleans up the empty history records for all locks.
1636
1637        Returns:
1638        - int: The number of locks cleaned up.
1639        """
1640        count = 0
1641        if lock in self.__vault.history:
1642            if len(self.__vault.history[lock]) <= 0:
1643                count += 1
1644                del self.__vault.history[lock]
1645            return count
1646        for key in self.__vault.history:
1647            if len(self.__vault.history[key]) <= 0:
1648                count += 1
1649                del self.__vault.history[key]
1650        return count

Cleans up the empty history records of actions performed on the ZakatTracker instance.

Parameters:

  • lock (Timestamp, 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:
1720    def nolock(self) -> bool:
1721        """
1722        Check if the vault lock is currently not set.
1723
1724        Parameters:
1725        None
1726
1727        Returns:
1728        - bool: True if the vault lock is not set, False otherwise.
1729        """
1730        return self.__vault.lock is None

Check if the vault lock is currently not set.

Parameters: None

Returns:

  • bool: True if the vault lock is not set, False otherwise.
def lock(self) -> Optional[Timestamp]:
1745    def lock(self) -> Optional[Timestamp]:
1746        """
1747        Acquires a lock on the ZakatTracker instance.
1748
1749        Parameters:
1750        None
1751
1752        Returns:
1753        - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later.
1754        """
1755        return self.__step()

Acquires a lock on the ZakatTracker instance.

Parameters: None

Returns:

  • Optional[Timestamp]: The lock ID. This ID can be used to release the lock later.
def steps(self) -> dict:
1757    def steps(self) -> dict:
1758        """
1759        Returns a copy of the history of steps taken in the ZakatTracker.
1760
1761        The history is a dictionary where each key is a unique identifier for a step,
1762        and the corresponding value is a dictionary containing information about the step.
1763
1764        Parameters:
1765        None
1766
1767        Returns:
1768        - dict: A copy of the history of steps taken in the ZakatTracker.
1769        """
1770        return {
1771            lock: {
1772                timestamp: dataclasses.asdict(history)
1773                for timestamp, history in steps.items()
1774            }
1775            for lock, steps in self.__vault.history.items()
1776        }

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.

Parameters: None

Returns:

  • dict: A copy of the history of steps taken in the ZakatTracker.
def free( self, lock: Timestamp, auto_save: bool = True) -> bool:
1778    def free(self, lock: Timestamp, auto_save: bool = True) -> bool:
1779        """
1780        Releases the lock on the database.
1781
1782        Parameters:
1783        - lock (Timestamp): The lock ID to be released.
1784        - auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
1785
1786        Returns:
1787        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
1788        """
1789        if lock == self.__vault.lock:
1790            self.clean_history(lock)
1791            self.__vault.lock = None
1792            if auto_save and not self.memory_mode():
1793                return self.save(self.path())
1794            return True
1795        return False

Releases the lock on the database.

Parameters:

  • lock (Timestamp): The lock ID to be released.
  • auto_save (bool, optional): 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 recall( self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool:
1797    def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool:
1798        """
1799        Revert the last operation.
1800
1801        Parameters:
1802        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
1803        - lock (Timestamp, optional): An optional lock value to ensure the recall
1804                operation is performed on the expected history entry. If provided,
1805                it checks if the current lock and the most recent history key
1806                match the given lock value. Defaults to None.
1807        - debug (bool, optional): If True, the function will print debug information. Default is False.
1808
1809        Returns:
1810        - bool: True if the operation was successful, False otherwise.
1811        """
1812        if not self.nolock() or len(self.__vault.history) == 0:
1813            return False
1814        if len(self.__vault.history) <= 0:
1815            return False
1816        ref = sorted(self.__vault.history.keys())[-1]
1817        if debug:
1818            print('recall', ref)
1819        memory = sorted(self.__vault.history[ref], reverse=True)
1820        if debug:
1821            print(type(memory), 'memory', memory)
1822        if lock is not None:
1823            assert self.__vault.lock == lock, "Invalid current lock"
1824            assert ref == lock, "Invalid last lock"
1825            assert self.__history(), "History mode should be enabled, found off!!!"
1826        sub_positive_log_negative = 0
1827        for i in memory:
1828            x = self.__vault.history[ref][i]
1829            if debug:
1830                print(type(x), x)
1831            if x.action != Action.REPORT:
1832                assert x.account is not None
1833                if x.action != Action.EXCHANGE:
1834                    assert self.account_exists(x.account)
1835            match x.action:
1836                case Action.CREATE:
1837                    if debug:
1838                        print('account', self.__vault.account[x.account])
1839                    assert len(self.__vault.account[x.account].box) == 0
1840                    assert len(self.__vault.account[x.account].log) == 0
1841                    assert self.__vault.account[x.account].balance == 0
1842                    assert self.__vault.account[x.account].count == 0
1843                    assert self.__vault.account[x.account].name == ''
1844                    if dry:
1845                        continue
1846                    del self.__vault.account[x.account]
1847
1848                case Action.NAME:
1849                    assert x.value is not None
1850                    if dry:
1851                        continue
1852                    self.__vault.account[x.account].name = x.value
1853
1854                case Action.TRACK:
1855                    assert x.value is not None
1856                    assert x.ref is not None
1857                    if dry:
1858                        continue
1859                    self.__vault.account[x.account].balance -= x.value
1860                    self.__vault.account[x.account].count -= 1
1861                    del self.__vault.account[x.account].box[x.ref]
1862
1863                case Action.LOG:
1864                    assert x.ref in self.__vault.account[x.account].log
1865                    assert x.value is not None
1866                    if dry:
1867                        continue
1868                    if sub_positive_log_negative == -x.value:
1869                        self.__vault.account[x.account].count -= 1
1870                        sub_positive_log_negative = 0
1871                    box_ref = self.__vault.account[x.account].log[x.ref].ref
1872                    if not box_ref is None:
1873                        assert self.box_exists(x.account, box_ref)
1874                        box_value = self.__vault.account[x.account].log[x.ref].value
1875                        assert box_value < 0
1876
1877                        try:
1878                            self.__vault.account[x.account].box[box_ref].rest += -box_value
1879                        except TypeError:
1880                            self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value)
1881
1882                        try:
1883                            self.__vault.account[x.account].balance += -box_value
1884                        except TypeError:
1885                            self.__vault.account[x.account].balance += decimal.Decimal(-box_value)
1886
1887                        self.__vault.account[x.account].count -= 1
1888                    del self.__vault.account[x.account].log[x.ref]
1889
1890                case Action.SUBTRACT:
1891                    assert x.ref in self.__vault.account[x.account].box
1892                    assert x.value is not None
1893                    if dry:
1894                        continue
1895                    self.__vault.account[x.account].box[x.ref].rest += x.value
1896                    self.__vault.account[x.account].balance += x.value
1897                    sub_positive_log_negative = x.value
1898
1899                case Action.ADD_FILE:
1900                    assert x.ref in self.__vault.account[x.account].log
1901                    assert x.file is not None
1902                    assert dry or x.file in self.__vault.account[x.account].log[x.ref].file
1903                    if dry:
1904                        continue
1905                    del self.__vault.account[x.account].log[x.ref].file[x.file]
1906
1907                case Action.REMOVE_FILE:
1908                    assert x.ref in self.__vault.account[x.account].log
1909                    assert x.file is not None
1910                    assert x.value is not None
1911                    if dry:
1912                        continue
1913                    self.__vault.account[x.account].log[x.ref].file[x.file] = x.value
1914
1915                case Action.BOX_TRANSFER:
1916                    assert x.ref in self.__vault.account[x.account].box
1917                    assert x.value is not None
1918                    if dry:
1919                        continue
1920                    self.__vault.account[x.account].box[x.ref].rest -= x.value
1921
1922                case Action.EXCHANGE:
1923                    assert x.account in self.__vault.exchange
1924                    assert x.ref in self.__vault.exchange[x.account]
1925                    if dry:
1926                        continue
1927                    del self.__vault.exchange[x.account][x.ref]
1928
1929                case Action.REPORT:
1930                    assert x.ref in self.__vault.report
1931                    if dry:
1932                        continue
1933                    del self.__vault.report[x.ref]
1934
1935                case Action.ZAKAT:
1936                    assert x.ref in self.__vault.account[x.account].box
1937                    assert x.key is not None
1938                    assert hasattr(self.__vault.account[x.account].box[x.ref].zakat, x.key)
1939                    if dry:
1940                        continue
1941                    match x.math:
1942                        case MathOperation.ADDITION:
1943                            setattr(
1944                                self.__vault.account[x.account].box[x.ref].zakat,
1945                                x.key,
1946                                getattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) - x.value,
1947                            )
1948                        case MathOperation.EQUAL:
1949                            setattr(
1950                                self.__vault.account[x.account].box[x.ref].zakat,
1951                                x.key,
1952                                x.value,
1953                            )
1954                        case MathOperation.SUBTRACTION:
1955                            setattr(
1956                                self.__vault.account[x.account].box[x.ref],
1957                                x.key,
1958                                getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value,
1959                            )
1960
1961        if not dry:
1962            del self.__vault.history[ref]
1963        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.
  • lock (Timestamp, optional): An optional lock value to ensure the recall operation is performed on the expected history entry. If provided, it checks if the current lock and the most recent history key match the given lock value. Defaults to None.
  • debug (bool, optional): If True, the function will print debug information. Default is False.

Returns:

  • bool: True if the operation was successful, False otherwise.
def vault(self) -> dict:
1965    def vault(self) -> dict:
1966        """
1967        Returns a copy of the internal vault dictionary.
1968
1969        This method is used to retrieve the current state of the ZakatTracker object.
1970        It provides a snapshot of the internal data structure, allowing for further
1971        processing or analysis.
1972
1973        Parameters:
1974        None
1975
1976        Returns:
1977        - dict: A copy of the internal vault dictionary.
1978        """
1979        return dataclasses.asdict(self.__vault)

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.

Parameters: None

Returns:

  • dict: A copy of the internal vault dictionary.
@staticmethod
def stats_init() -> FileStats:
1981    @staticmethod
1982    def stats_init() -> FileStats:
1983        """
1984        Initialize and return the initial file statistics.
1985
1986        Returns:
1987        - FileStats: A :class:`FileStats` instance with initial values
1988            of 0 bytes for both RAM and database.
1989        """
1990        return FileStats(
1991            database=SizeInfo(0, '0'),
1992            ram=SizeInfo(0, '0'),
1993        )

Initialize and return the initial file statistics.

Returns:

  • FileStats: A FileStats instance with initial values of 0 bytes for both RAM and database.
def stats(self, ignore_ram: bool = True) -> FileStats:
1995    def stats(self, ignore_ram: bool = True) -> FileStats:
1996        """
1997        Calculates and returns statistics about the object's data storage.
1998
1999        This method determines the size of the database file on disk and the
2000        size of the data currently held in RAM (likely within a dictionary).
2001        Both sizes are reported in bytes and in a human-readable format
2002        (e.g., KB, MB).
2003
2004        Parameters:
2005        - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
2006
2007        Returns:
2008        - FileStats: A dataclass containing the following statistics:
2009
2010            * 'database': A tuple with two elements:
2011                - The database file size in bytes (float).
2012                - The database file size in human-readable format (str).
2013            * 'ram': A tuple with two elements:
2014                - The RAM usage (dictionary size) in bytes (float).
2015                - The RAM usage in human-readable format (str).
2016
2017        Example:
2018        ```bash
2019        >>> x = ZakatTracker()
2020        >>> stats = x.stats()
2021        >>> print(stats.database)
2022        SizeInfo(bytes=256000, human_readable='250.0 KB')
2023        >>> print(stats.ram)
2024        SizeInfo(bytes=12345, human_readable='12.1 KB')
2025        ```
2026        """
2027        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
2028        file_size = os.path.getsize(self.path())
2029        return FileStats(
2030            database=SizeInfo(file_size, self.human_readable_size(file_size)),
2031            ram=SizeInfo(ram_size, self.human_readable_size(ram_size)),
2032        )

Calculates and returns statistics about the object's data storage.

This method determines the size of the database file on disk and the size of the data currently held in RAM (likely within a dictionary). Both sizes are reported in bytes and in a human-readable format (e.g., KB, MB).

Parameters:

  • ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True

Returns:

  • FileStats: A dataclass containing the following statistics:
* 'database': A tuple with two elements:
    - The database file size in bytes (float).
    - The database file size in human-readable format (str).
* 'ram': A tuple with two elements:
    - The RAM usage (dictionary size) in bytes (float).
    - The RAM usage in human-readable format (str).

Example:

>>> x = ZakatTracker()
>>> stats = x.stats()
>>> print(stats.database)
SizeInfo(bytes=256000, human_readable='250.0 KB')
>>> print(stats.ram)
SizeInfo(bytes=12345, human_readable='12.1 KB')
def files(self) -> list[FileInfo]:
2034    def files(self) -> list[FileInfo]:
2035        """
2036        Retrieves information about files associated with this class.
2037
2038        This class method provides a standardized way to gather details about
2039        files used by the class for storage, snapshots, and CSV imports.
2040
2041        Parameters:
2042        None
2043
2044        Returns:
2045        - list[FileInfo]: A list of dataclass, each containing information
2046            about a specific file:
2047
2048            * type (str): The type of file ('database', 'snapshot', 'import_csv').
2049            * path (str): The full file path.
2050            * exists (bool): Whether the file exists on the filesystem.
2051            * size (int): The file size in bytes (0 if the file doesn't exist).
2052            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
2053        """
2054        result = []
2055        for file_type, path in {
2056            'database': self.path(),
2057            'snapshot': self.snapshot_cache_path(),
2058            'import_csv': self.import_csv_cache_path(),
2059        }.items():
2060            exists = os.path.exists(path)
2061            size = os.path.getsize(path) if exists else 0
2062            human_readable_size = self.human_readable_size(size) if exists else '0'
2063            result.append(FileInfo(
2064                type=file_type,
2065                path=path,
2066                exists=exists,
2067                size=size,
2068                human_readable_size=human_readable_size,
2069            ))
2070        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.

Parameters: None

Returns:

  • list[FileInfo]: A list of dataclass, 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').
def account_exists(self, account: AccountID) -> bool:
2072    def account_exists(self, account: AccountID) -> bool:
2073        """
2074        Check if the given account exists in the vault.
2075
2076        Parameters:
2077        - account (AccountID): The account reference to check.
2078
2079        Returns:
2080        - bool: True if the account exists, False otherwise.
2081        """
2082        account = AccountID(account)
2083        return account in self.__vault.account

Check if the given account exists in the vault.

Parameters:

  • account (AccountID): The account reference to check.

Returns:

  • bool: True if the account exists, False otherwise.
def box_size(self, account: AccountID) -> int:
2085    def box_size(self, account: AccountID) -> int:
2086        """
2087        Calculate the size of the box for a specific account.
2088
2089        Parameters:
2090        - account (AccountID): The account reference for which the box size needs to be calculated.
2091
2092        Returns:
2093        - int: The size of the box for the given account. If the account does not exist, -1 is returned.
2094        """
2095        if self.account_exists(account):
2096            return len(self.__vault.account[account].box)
2097        return -1

Calculate the size of the box for a specific account.

Parameters:

  • account (AccountID): The account reference 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: AccountID) -> int:
2099    def log_size(self, account: AccountID) -> int:
2100        """
2101        Get the size of the log for a specific account.
2102
2103        Parameters:
2104        - account (AccountID): The account reference for which the log size needs to be calculated.
2105
2106        Returns:
2107        - int: The size of the log for the given account. If the account does not exist, -1 is returned.
2108        """
2109        if self.account_exists(account):
2110            return len(self.__vault.account[account].log)
2111        return -1

Get the size of the log for a specific account.

Parameters:

  • account (AccountID): The account reference 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 hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
2113    @staticmethod
2114    def hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
2115        """
2116        Calculates the hash of given byte data using the specified algorithm.
2117
2118        Parameters:
2119        - data (bytes): The byte data to hash.
2120        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2121
2122        Returns:
2123        - str: The hexadecimal representation of the data's hash.
2124        """
2125        hash_obj = hashlib.new(algorithm)
2126        hash_obj.update(data)
2127        return hash_obj.hexdigest()

Calculates the hash of given byte data using the specified algorithm.

Parameters:

  • data (bytes): The byte data to hash.
  • algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.

Returns:

  • str: The hexadecimal representation of the data's hash.
@staticmethod
def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
2129    @staticmethod
2130    def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
2131        """
2132        Calculates the hash of a file using the specified algorithm.
2133
2134        Parameters:
2135        - file_path (str): The path to the file.
2136        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2137
2138        Returns:
2139        - str: The hexadecimal representation of the file's hash.
2140        """
2141        hash_obj = hashlib.new(algorithm)  # Create the hash object
2142        with open(file_path, 'rb') as file:  # Open file in binary mode for reading
2143            for chunk in iter(lambda: file.read(4096), b''):  # Read file in chunks
2144                hash_obj.update(chunk)
2145        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):
2147    def snapshot_cache_path(self):
2148        """
2149        Generate the path for the cache file used to store snapshots.
2150
2151        The cache file is a json file that stores the timestamps of the snapshots.
2152        The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'.
2153
2154        Parameters:
2155        None
2156
2157        Returns:
2158        - str: The path to the cache file.
2159        """
2160        path = str(self.path())
2161        ext = self.ext()
2162        ext_len = len(ext)
2163        if path.endswith(f'.{ext}'):
2164            path = path[:-ext_len - 1]
2165        _, filename = os.path.split(path + f'.snapshots.{ext}')
2166        return self.base_path(filename)

Generate the path for the cache file used to store snapshots.

The cache file is a json file that stores the timestamps of the snapshots. The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'.

Parameters: None

Returns:

  • str: The path to the cache file.
def snapshot(self) -> bool:
2168    def snapshot(self) -> bool:
2169        """
2170        This function creates a snapshot of the current database state.
2171
2172        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
2173        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
2174        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
2175        in a new json 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.
2176
2177        Parameters:
2178        None
2179
2180        Returns:
2181        - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
2182        """
2183        current_hash = self.hash_file(self.path())
2184        cache: dict[str, int] = {}  # hash: time_ns
2185        try:
2186            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2187                cache = json.load(stream, cls=JSONDecoder)
2188        except:
2189            pass
2190        if current_hash in cache:
2191            return True
2192        ref = time.time_ns()
2193        cache[current_hash] = ref
2194        if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')):
2195            return False
2196        with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream:
2197            stream.write(json.dumps(cache, cls=JSONEncoder))
2198        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 json 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]]:
2200    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
2201            -> dict[int, tuple[str, str, bool]]:
2202        """
2203        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
2204
2205        Parameters:
2206        - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
2207        - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.
2208
2209        Returns:
2210        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
2211        and the values are tuples containing the snapshot's hash, path, and existence status.
2212        """
2213        cache: dict[str, int] = {}  # hash: time_ns
2214        try:
2215            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2216                cache = json.load(stream, cls=JSONDecoder)
2217        except:
2218            pass
2219        if not cache:
2220            return {}
2221        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
2222        for hash_file, ref in cache.items():
2223            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
2224            exists = os.path.exists(path)
2225            valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True
2226            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
2227                continue
2228            if exists or not hide_missing:
2229                result[ref] = (hash_file, path, exists)
2230        return result

Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.

Parameters:

  • hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
  • verified_hash_only (bool, optional): 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 ref_exists( self, account: AccountID, ref_type: str, ref: Timestamp) -> bool:
2232    def ref_exists(self, account: AccountID, ref_type: str, ref: Timestamp) -> bool:
2233        """
2234        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
2235
2236        Parameters:
2237        - account (AccountID): The account reference for which to check the existence of the reference.
2238        - ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
2239        - ref (Timestamp): The reference (transaction) number to check for existence.
2240
2241        Returns:
2242        - bool: True if the reference exists for the given account and reference type, False otherwise.
2243        """
2244        account = AccountID(account)
2245        if account in self.__vault.account:
2246            return ref in getattr(self.__vault.account[account], ref_type)
2247        return False

Check if a specific reference (transaction) exists in the vault for a given account and reference type.

Parameters:

  • account (AccountID): The account reference for which to check the existence of the reference.
  • ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
  • ref (Timestamp): 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: AccountID, ref: Timestamp) -> bool:
2249    def box_exists(self, account: AccountID, ref: Timestamp) -> bool:
2250        """
2251        Check if a specific box (transaction) exists in the vault for a given account and reference.
2252
2253        Parameters:
2254        - account (AccountID): The account reference for which to check the existence of the box.
2255        - ref (Timestamp): The reference (transaction) number to check for existence.
2256
2257        Returns:
2258        - bool: True if the box exists for the given account and reference, False otherwise.
2259        """
2260        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 (AccountID): The account reference for which to check the existence of the box.
  • ref (Timestamp): 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, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = '1', created_time_ns: Optional[Timestamp] = None, debug: bool = False) -> Optional[Timestamp]:
2262    def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'),
2263              created_time_ns: Optional[Timestamp] = None,
2264              debug: bool = False) -> Optional[Timestamp]:
2265        """
2266        This function tracks a transaction for a specific account, so it do creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
2267
2268        Parameters:
2269        - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
2270        - desc (str, optional): The description of the transaction. Default is an empty string.
2271        - account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'.
2272        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None.
2273        - debug (bool, optional): Whether to print debug information. Default is False.
2274
2275        Returns:
2276        - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD).
2277
2278        Raises:
2279        - ValueError: The created_time_ns should be greater than zero.
2280        - ValueError: The log transaction happened again in the same nanosecond time.
2281        - ValueError: The box transaction happened again in the same nanosecond time.
2282        """
2283        return self.__track(
2284            unscaled_value=unscaled_value,
2285            desc=desc,
2286            account=account,
2287            logging=True,
2288            created_time_ns=created_time_ns,
2289            debug=debug,
2290        )

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

Parameters:

  • unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
  • desc (str, optional): The description of the transaction. Default is an empty string.
  • account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'.
  • created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None.
  • debug (bool, optional): Whether to print debug information. Default is False.

Returns:

  • Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD).

Raises:

  • ValueError: The created_time_ns should be greater than zero.
  • 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: AccountID, ref: Timestamp) -> bool:
2358    def log_exists(self, account: AccountID, ref: Timestamp) -> bool:
2359        """
2360        Checks if a specific transaction log entry exists for a given account.
2361
2362        Parameters:
2363        - account (AccountID): The account reference associated with the transaction log.
2364        - ref (Timestamp): The reference to the transaction log entry.
2365
2366        Returns:
2367        - bool: True if the transaction log entry exists, False otherwise.
2368        """
2369        return self.ref_exists(account, 'log', ref)

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

Parameters:

  • account (AccountID): The account reference associated with the transaction log.
  • ref (Timestamp): The reference to the transaction log entry.

Returns:

  • bool: True if the transaction log entry exists, False otherwise.
def exchange( self, account: AccountID, created_time_ns: Optional[Timestamp] = None, rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
2422    def exchange(self, account: AccountID, created_time_ns: Optional[Timestamp] = None,
2423                 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
2424        """
2425        This method is used to record or retrieve exchange rates for a specific account.
2426
2427        Parameters:
2428        - account (AccountID): The account reference for which the exchange rate is being recorded or retrieved.
2429        - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
2430        - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
2431        - description (str, optional): A description of the exchange rate.
2432        - debug (bool, optional): Whether to print debug information. Default is False.
2433
2434        Returns:
2435        - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
2436        it returns a dictionary with default values for the rate and description.
2437
2438        Raises:
2439        - ValueError: The created should be greater than zero.
2440        """
2441        if debug:
2442            print('exchange', f'debug={debug}')
2443        account = AccountID(account)
2444        if created_time_ns is None:
2445            created_time_ns = Time.time()
2446        if created_time_ns <= 0:
2447            raise ValueError('The created should be greater than zero.')
2448        if rate is not None:
2449            if rate <= 0:
2450                return Exchange()
2451            if account not in self.__vault.exchange:
2452                self.__vault.exchange[account] = {}
2453            if len(self.__vault.exchange[account]) == 0 and rate <= 1:
2454                return Exchange(time=created_time_ns, rate=1)
2455            no_lock = self.nolock()
2456            lock = self.__lock()
2457            self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description)
2458            self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate)
2459            if no_lock:
2460                assert lock is not None
2461                self.free(lock)
2462            if debug:
2463                print('exchange-created-1',
2464                      f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2465
2466        if account in self.__vault.exchange:
2467            valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns]
2468            if valid_rates:
2469                latest_rate = max(valid_rates, key=lambda x: x[0])
2470                if debug:
2471                    print('exchange-read-1',
2472                          f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}',
2473                          'latest_rate', latest_rate)
2474                result = latest_rate[1]
2475                result.time = latest_rate[0]
2476                return result  # إرجاع قاموس يحتوي على المعدل والوصف
2477        if debug:
2478            print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2479        return Exchange(time=created_time_ns, rate=1, description=None)  # إرجاع القيمة الافتراضية مع وصف فارغ

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

Parameters:

  • account (AccountID): The account reference for which the exchange rate is being recorded or retrieved.
  • created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
  • rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
  • description (str, optional): A description of the exchange rate.
  • debug (bool, optional): Whether to print debug information. Default is False.

Returns:

  • Exchange: 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.

Raises:

  • ValueError: The created should be greater than zero.
@staticmethod
def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
2481    @staticmethod
2482    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
2483        """
2484        This function calculates the exchanged amount of a currency.
2485
2486        Parameters:
2487        - x (float): The original amount of the currency.
2488        - x_rate (float): The exchange rate of the original currency.
2489        - y_rate (float): The exchange rate of the target currency.
2490
2491        Returns:
2492        - float: The exchanged amount of the target currency.
2493        """
2494        return (x * x_rate) / y_rate

This function calculates the exchanged amount of a currency.

Parameters:

  • 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[AccountID, dict[Timestamp, Exchange]]:
2496    def exchanges(self) -> dict[AccountID, dict[Timestamp, Exchange]]:
2497        """
2498        Retrieve the recorded exchange rates for all accounts.
2499
2500        Parameters:
2501        None
2502
2503        Returns:
2504        - dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates.
2505        The keys are account references or numbers, and the values are dictionaries containing the exchange rates.
2506        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
2507        """
2508        return self.__vault.exchange.copy()

Retrieve the recorded exchange rates for all accounts.

Parameters: None

Returns:

  • dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates. The keys are account references 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[AccountID, AccountDetails]:
2510    def accounts(self) -> dict[AccountID, AccountDetails]:
2511        """
2512        Returns a dictionary containing account references as keys and their respective account details as values.
2513
2514        Parameters:
2515        None
2516
2517        Returns:
2518        - dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details.
2519        """
2520        return {
2521            account_id: AccountDetails(
2522                account_id=account_id,
2523                account_name=self.__vault.account[account_id].name,
2524                balance=self.__vault.account[account_id].balance,
2525            )
2526            for account_id in self.__vault.account
2527        }

Returns a dictionary containing account references as keys and their respective account details as values.

Parameters: None

Returns:

  • dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details.
def boxes( self, account: AccountID) -> dict[Timestamp, Box]:
2529    def boxes(self, account: AccountID) -> dict[Timestamp, Box]:
2530        """
2531        Retrieve the boxes (transactions) associated with a specific account.
2532
2533        Parameters:
2534        - account (AccountID): The account reference for which to retrieve the boxes.
2535
2536        Returns:
2537        - dict[Timestamp, Box]: A dictionary containing the boxes associated with the given account.
2538        If the account does not exist, an empty dictionary is returned.
2539        """
2540        if self.account_exists(account):
2541            return self.__vault.account[account].box
2542        return {}

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

Parameters:

  • account (AccountID): The account reference for which to retrieve the boxes.

Returns:

  • dict[Timestamp, Box]: 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: AccountID) -> dict[Timestamp, Log]:
2544    def logs(self, account: AccountID) -> dict[Timestamp, Log]:
2545        """
2546        Retrieve the logs (transactions) associated with a specific account.
2547
2548        Parameters:
2549        - account (AccountID): The account reference for which to retrieve the logs.
2550
2551        Returns:
2552        - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account.
2553        If the account does not exist, an empty dictionary is returned.
2554        """
2555        if self.account_exists(account):
2556            return self.__vault.account[account].log
2557        return {}

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

Parameters:

  • account (AccountID): The account reference for which to retrieve the logs.

Returns:

  • dict[Timestamp, Log]: A dictionary containing the logs associated with the given account. If the account does not exist, an empty dictionary is returned.
def timeline( self, weekday: WeekDay = <WeekDay.FRIDAY: 4>, debug: bool = False) -> Timeline:
2559    def timeline(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False) -> Timeline:
2560        """
2561        Aggregates transaction logs into a structured timeline.
2562
2563        This method retrieves transaction logs from all accounts and organizes them
2564        into daily, weekly, monthly, and yearly summaries. Each level of the
2565        timeline includes a `TimeSummary` object with the total positive, negative,
2566        and overall values for that period. The daily level also includes a list
2567        of individual `Transaction` records.
2568
2569        Parameters:
2570        - weekday (WeekDay, optional): The day of the week to use as the anchor
2571                for weekly summaries. Defaults to WeekDay.FRIDAY.
2572        - debug (bool, optional): If True, prints intermediate debug information
2573                during processing. Defaults to False.
2574
2575        Returns:
2576        - Timeline: An object containing the aggregated transaction data, organized
2577                into daily, weekly, monthly, and yearly summaries. The 'daily'
2578                attribute is a dictionary where keys are dates (YYYY-MM-DD) and
2579                values are `DailyRecords` objects. The 'weekly' attribute is a
2580                dictionary where keys are the starting datetime of the week and
2581                values are `TimeSummary` objects. The 'monthly' attribute is a
2582                dictionary where keys are year-month strings (YYYY-MM) and values
2583                are `TimeSummary` objects. The 'yearly' attribute is a dictionary
2584                where keys are years (YYYY) and values are `TimeSummary` objects.
2585
2586        Example:
2587        ```bash
2588        >>> from zakat import tracker
2589        >>> ledger = tracker(':memory:')
2590        >>> account1_id = ledger.create_account('account1')
2591        >>> account2_id = ledger.create_account('account2')
2592        >>> ledger.subtract(51, 'desc', account1_id)
2593        >>> ref = ledger.track(100, 'desc', account2_id)
2594        >>> ledger.add_file(account2_id, ref, 'file_0')
2595        >>> ledger.add_file(account2_id, ref, 'file_1')
2596        >>> ledger.add_file(account2_id, ref, 'file_2')
2597        >>> ledger.timeline()
2598        Timeline(
2599            daily={
2600                "2025-04-06": DailyRecords(
2601                    positive=10000,
2602                    negative=5100,
2603                    total=4900,
2604                    rows=[
2605                        Transaction(
2606                            account="account2",
2607                            account_id="63879638114290122752",
2608                            desc="desc2",
2609                            file={
2610                                63879638220705865728: "file_0",
2611                                63879638223391350784: "file_1",
2612                                63879638225766047744: "file_2",
2613                            },
2614                            value=10000,
2615                            time=63879638181936513024,
2616                            transfer=False,
2617                        ),
2618                        Transaction(
2619                            account="account1",
2620                            account_id="63879638104007106560",
2621                            desc="desc",
2622                            file={},
2623                            value=-5100,
2624                            time=63879638149199421440,
2625                            transfer=False,
2626                        ),
2627                    ],
2628                )
2629            },
2630            weekly={
2631                datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary(
2632                    positive=10000, negative=0, total=10000
2633                ),
2634                datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary(
2635                    positive=0, negative=5100, total=-5100
2636                ),
2637            },
2638            monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)},
2639            yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)},
2640        )
2641        ```
2642        """
2643        logs: dict[Timestamp, list[Transaction]] = {}
2644        for account_id in self.accounts():
2645            for log_ref, log in self.logs(account_id).items():
2646                if log_ref not in logs:
2647                    logs[log_ref] = []
2648                logs[log_ref].append(Transaction(
2649                    account=self.name(account_id),
2650                    account_id=account_id,
2651                    desc=log.desc,
2652                    file=log.file,
2653                    value=log.value,
2654                    time=log_ref,
2655                    transfer=False,
2656                ))
2657        if debug:
2658            print('logs', logs)
2659        y = Timeline()
2660        for i in sorted(logs, reverse=True):
2661            dt = Time.time_to_datetime(i)
2662            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
2663            weekly = dt - datetime.timedelta(days=weekday.value)
2664            monthly = f'{dt.year}-{dt.month:02d}'
2665            yearly = dt.year
2666            # daily
2667            if daily not in y.daily:
2668                y.daily[daily] = DailyRecords()
2669            transfer = len(logs[i]) > 1
2670            if debug:
2671                print('logs[i]', logs[i])
2672            for z in logs[i]:
2673                if debug:
2674                    print('z', z)
2675                # daily
2676                value = z.value
2677                if value > 0:
2678                    y.daily[daily].positive += value
2679                else:
2680                    y.daily[daily].negative += -value
2681                y.daily[daily].total += value
2682                z.transfer = transfer
2683                y.daily[daily].rows.append(z)
2684                # weekly
2685                if weekly not in y.weekly:
2686                    y.weekly[weekly] = TimeSummary()
2687                if value > 0:
2688                    y.weekly[weekly].positive += value
2689                else:
2690                    y.weekly[weekly].negative += -value
2691                y.weekly[weekly].total += value
2692                # monthly
2693                if monthly not in y.monthly:
2694                    y.monthly[monthly] = TimeSummary()
2695                if value > 0:
2696                    y.monthly[monthly].positive += value
2697                else:
2698                    y.monthly[monthly].negative += -value
2699                y.monthly[monthly].total += value
2700                # yearly
2701                if yearly not in y.yearly:
2702                    y.yearly[yearly] = TimeSummary()
2703                if value > 0:
2704                    y.yearly[yearly].positive += value
2705                else:
2706                    y.yearly[yearly].negative += -value
2707                y.yearly[yearly].total += value
2708        if debug:
2709            print('y', y)
2710        return y

Aggregates transaction logs into a structured timeline.

This method retrieves transaction logs from all accounts and organizes them into daily, weekly, monthly, and yearly summaries. Each level of the timeline includes a TimeSummary object with the total positive, negative, and overall values for that period. The daily level also includes a list of individual Transaction records.

Parameters:

  • weekday (WeekDay, optional): The day of the week to use as the anchor for weekly summaries. Defaults to WeekDay.FRIDAY.
  • debug (bool, optional): If True, prints intermediate debug information during processing. Defaults to False.

Returns:

  • Timeline: An object containing the aggregated transaction data, organized into daily, weekly, monthly, and yearly summaries. The 'daily' attribute is a dictionary where keys are dates (YYYY-MM-DD) and values are DailyRecords objects. The 'weekly' attribute is a dictionary where keys are the starting datetime of the week and values are TimeSummary objects. The 'monthly' attribute is a dictionary where keys are year-month strings (YYYY-MM) and values are TimeSummary objects. The 'yearly' attribute is a dictionary where keys are years (YYYY) and values are TimeSummary objects.

Example:

>>> from zakat import tracker
>>> ledger = tracker(':memory:')
>>> account1_id = ledger.create_account('account1')
>>> account2_id = ledger.create_account('account2')
>>> ledger.subtract(51, 'desc', account1_id)
>>> ref = ledger.track(100, 'desc', account2_id)
>>> ledger.add_file(account2_id, ref, 'file_0')
>>> ledger.add_file(account2_id, ref, 'file_1')
>>> ledger.add_file(account2_id, ref, 'file_2')
>>> ledger.timeline()
Timeline(
    daily={
        "2025-04-06": DailyRecords(
            positive=10000,
            negative=5100,
            total=4900,
            rows=[
                Transaction(
                    account="account2",
                    account_id="63879638114290122752",
                    desc="desc2",
                    file={
                        63879638220705865728: "file_0",
                        63879638223391350784: "file_1",
                        63879638225766047744: "file_2",
                    },
                    value=10000,
                    time=63879638181936513024,
                    transfer=False,
                ),
                Transaction(
                    account="account1",
                    account_id="63879638104007106560",
                    desc="desc",
                    file={},
                    value=-5100,
                    time=63879638149199421440,
                    transfer=False,
                ),
            ],
        )
    },
    weekly={
        datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary(
            positive=10000, negative=0, total=10000
        ),
        datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary(
            positive=0, negative=5100, total=-5100
        ),
    },
    monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)},
    yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)},
)
def add_file( self, account: AccountID, ref: Timestamp, path: str) -> Timestamp:
2712    def add_file(self, account: AccountID, ref: Timestamp, path: str) -> Timestamp:
2713        """
2714        Adds a file reference to a specific transaction log entry in the vault.
2715
2716        Parameters:
2717        - account (AccountID): The account reference associated with the transaction log.
2718        - ref (Timestamp): The reference to the transaction log entry.
2719        - path (str): The path of the file to be added.
2720
2721        Returns:
2722        - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
2723        """
2724        if self.account_exists(account):
2725            if ref in self.__vault.account[account].log:
2726                no_lock = self.nolock()
2727                lock = self.__lock()
2728                file_ref = Time.time()
2729                self.__vault.account[account].log[ref].file[file_ref] = path
2730                self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref)
2731                if no_lock:
2732                    assert lock is not None
2733                    self.free(lock)
2734                return file_ref
2735        return Timestamp(0)

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

Parameters:

  • account (AccountID): The account reference associated with the transaction log.
  • ref (Timestamp): The reference to the transaction log entry.
  • path (str): The path of the file to be added.

Returns:

  • Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
def remove_file( self, account: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool:
2737    def remove_file(self, account: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool:
2738        """
2739        Removes a file reference from a specific transaction log entry in the vault.
2740
2741        Parameters:
2742        - account (AccountID): The account reference associated with the transaction log.
2743        - ref (Timestamp): The reference to the transaction log entry.
2744        - file_ref (Timestamp): The reference of the file to be removed.
2745
2746        Returns:
2747        - bool: True if the file reference is successfully removed, False otherwise.
2748        """
2749        if self.account_exists(account):
2750            if ref in self.__vault.account[account].log:
2751                if file_ref in self.__vault.account[account].log[ref].file:
2752                    no_lock = self.nolock()
2753                    lock = self.__lock()
2754                    x = self.__vault.account[account].log[ref].file[file_ref]
2755                    del self.__vault.account[account].log[ref].file[file_ref]
2756                    self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
2757                    if no_lock:
2758                        assert lock is not None
2759                        self.free(lock)
2760                    return True
2761        return False

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

Parameters:

  • account (AccountID): The account reference associated with the transaction log.
  • ref (Timestamp): The reference to the transaction log entry.
  • file_ref (Timestamp): The reference of the file to be removed.

Returns:

  • bool: True if the file reference is successfully removed, False otherwise.
def balance( self, account: AccountID = '1', cached: bool = True) -> int:
2763    def balance(self, account: AccountID = AccountID('1'), cached: bool = True) -> int:
2764        """
2765        Calculate and return the balance of a specific account.
2766
2767        Parameters:
2768        - account (AccountID, optional): The account reference. Default is '1'.
2769        - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
2770
2771        Returns:
2772        - int: The balance of the account.
2773
2774        Notes:
2775        - If cached is True, the function returns the cached balance.
2776        - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
2777        """
2778        account = AccountID(account)
2779        if cached:
2780            return self.__vault.account[account].balance
2781        x = 0
2782        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 (AccountID, optional): The account reference. Default is '1'.
  • cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.

Returns:

  • int: The balance of the account.

Notes:

  • 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: AccountID, status: Optional[bool] = None) -> bool:
2784    def hide(self, account: AccountID, status: Optional[bool] = None) -> bool:
2785        """
2786        Check or set the hide status of a specific account.
2787
2788        Parameters:
2789        - account (AccountID): The account reference.
2790        - status (bool, optional): The new hide status. If not provided, the function will return the current status.
2791
2792        Returns:
2793        - bool: The current or updated hide status of the account.
2794
2795        Raises:
2796        None
2797
2798        Example:
2799        ```bash
2800        >>> tracker = ZakatTracker()
2801        >>> ref = tracker.track(51, 'desc', 'account1')
2802        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
2803        False
2804        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
2805        True
2806        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
2807        True
2808        >>> tracker.hide('account1', False)
2809        False
2810        ```
2811        """
2812        if self.account_exists(account):
2813            if status is None:
2814                return self.__vault.account[account].hide
2815            self.__vault.account[account].hide = status
2816            return status
2817        return False

Check or set the hide status of a specific account.

Parameters:

  • account (AccountID): The account reference.
  • 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 account( self, name: str, exact: bool = True) -> Optional[AccountDetails]:
2819    def account(self, name: str, exact: bool = True) -> Optional[AccountDetails]:
2820        """
2821        Retrieves an AccountDetails object for the first account matching the given name.
2822
2823        This method searches for accounts with names that contain the provided 'name'
2824        (case-insensitive substring matching). If a match is found, it returns an
2825        AccountDetails object containing the account's ID, name and balance. If no matching
2826        account is found, it returns None.
2827
2828        Parameters:
2829        - name: The name (or partial name) of the account to retrieve.
2830        - exact: If True, performs a case-insensitive exact match.
2831                 If False, performs a case-insensitive substring search.
2832                 Defaults to True.
2833
2834        Returns:
2835        - AccountDetails: An AccountDetails object representing the found account, or None if no
2836            matching account exists.
2837        """
2838        for account_name, account_id in self.names(name).items():
2839            if not exact or account_name.lower() == name.lower():
2840                return AccountDetails(
2841                    account_id=account_id,
2842                    account_name=account_name,
2843                    balance=self.__vault.account[account_id].balance,
2844                )
2845        return None

Retrieves an AccountDetails object for the first account matching the given name.

This method searches for accounts with names that contain the provided 'name' (case-insensitive substring matching). If a match is found, it returns an AccountDetails object containing the account's ID, name and balance. If no matching account is found, it returns None.

Parameters:

  • name: The name (or partial name) of the account to retrieve.
  • exact: If True, performs a case-insensitive exact match. If False, performs a case-insensitive substring search. Defaults to True.

Returns:

  • AccountDetails: An AccountDetails object representing the found account, or None if no matching account exists.
def create_account(self, name: str) -> AccountID:
2847    def create_account(self, name: str) -> AccountID:
2848        """
2849        Creates a new account with the given name and returns its unique ID.
2850
2851        This method:
2852        1. Checks if an account with the same name (case-insensitive) already exists.
2853        2. Generates a unique `AccountID` based on the current time.
2854        3. Tracks the account creation internally.
2855        4. Sets the account's name.
2856        5. Verifies that the name was set correctly.
2857    
2858        Parameters:
2859        - name: The name of the new account.
2860    
2861        Returns:
2862        - AccountID: The unique `AccountID` of the newly created account.
2863    
2864        Raises:
2865        - AssertionError: Empty account name is forbidden.
2866        - AssertionError: Account name in number is forbidden.
2867        - AssertionError: If an account with the same name already exists (case-insensitive).
2868        - AssertionError: If the provided name does not match the name set for the account.
2869        """
2870        assert name.strip(), 'empty account name is forbidden'
2871        assert not name.isdigit() and not name.isdecimal() and not name.isnumeric() and not is_number(name), f'Account name({name}) in number is forbidden'
2872        account_ref = self.account(name, exact=True)
2873        # check if account not exists
2874        assert account_ref is None, f'account name({name}) already used'
2875        # create new account
2876        account_id = AccountID(Time.time())
2877        self.__track(0, '', account_id)
2878        new_name = self.name(
2879            account=account_id,
2880            new_name=name,
2881        )
2882        assert name == new_name
2883        return account_id

Creates a new account with the given name and returns its unique ID.

This method:

  1. Checks if an account with the same name (case-insensitive) already exists.
  2. Generates a unique AccountID based on the current time.
  3. Tracks the account creation internally.
  4. Sets the account's name.
  5. Verifies that the name was set correctly.

Parameters:

  • name: The name of the new account.

Returns:

  • AccountID: The unique AccountID of the newly created account.

Raises:

  • AssertionError: Empty account name is forbidden.
  • AssertionError: Account name in number is forbidden.
  • AssertionError: If an account with the same name already exists (case-insensitive).
  • AssertionError: If the provided name does not match the name set for the account.
def names(self, keyword: str = '') -> dict[str, AccountID]:
2885    def names(self, keyword: str = '') -> dict[str, AccountID]:
2886        """
2887        Retrieves a dictionary of account IDs and names, optionally filtered by a keyword.
2888
2889        Parameters:
2890        - keyword: An optional string to filter account names. If provided, only accounts whose
2891            names contain the keyword (case-insensitive) will be included in the result.
2892            Defaults to an empty string, which returns all accounts.
2893
2894        Returns:
2895        - A dictionary where keys are account names and values are AccountIDs. The dictionary
2896            contains only accounts that match the provided keyword (if any).
2897        """
2898        return {
2899            account.name: account_id
2900            for account_id, account in self.__vault.account.items()
2901            if keyword.lower() in account.name.lower()
2902        }

Retrieves a dictionary of account IDs and names, optionally filtered by a keyword.

Parameters:

  • keyword: An optional string to filter account names. If provided, only accounts whose names contain the keyword (case-insensitive) will be included in the result. Defaults to an empty string, which returns all accounts.

Returns:

  • A dictionary where keys are account names and values are AccountIDs. The dictionary contains only accounts that match the provided keyword (if any).
def name( self, account: AccountID, new_name: Optional[str] = None) -> str:
2904    def name(self, account: AccountID, new_name: Optional[str] = None) -> str:
2905        """
2906        Retrieves or sets the name of an account.
2907
2908        Parameters:
2909        - account: The AccountID of the account.
2910        - new_name: The new name to set for the account. If None, the current name is retrieved.
2911
2912        Returns:
2913        - The current name of the account if `new_name` is None, or the `new_name` if it is set.
2914
2915        Note: Returns an empty string if the account does not exist.
2916        """
2917        if self.account_exists(account):
2918            if new_name is None:
2919                return self.__vault.account[account].name
2920            assert new_name != ''
2921            no_lock = self.nolock()
2922            lock = self.__lock()
2923            self.__step(Action.NAME, account, value=self.__vault.account[account].name)
2924            self.__vault.account[account].name = new_name
2925            if no_lock:
2926                    assert lock is not None
2927                    self.free(lock)
2928            return new_name
2929        return ''

Retrieves or sets the name of an account.

Parameters:

  • account: The AccountID of the account.
  • new_name: The new name to set for the account. If None, the current name is retrieved.

Returns:

  • The current name of the account if new_name is None, or the new_name if it is set.

Note: Returns an empty string if the account does not exist.

def zakatable( self, account: AccountID, status: Optional[bool] = None) -> bool:
2931    def zakatable(self, account: AccountID, status: Optional[bool] = None) -> bool:
2932        """
2933        Check or set the zakatable status of a specific account.
2934
2935        Parameters:
2936        - account (AccountID): The account reference.
2937        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
2938
2939        Returns:
2940        - bool: The current or updated zakatable status of the account.
2941
2942        Raises:
2943        None
2944
2945        Example:
2946        ```bash
2947        >>> tracker = ZakatTracker()
2948        >>> ref = tracker.track(51, 'desc', 'account1')
2949        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
2950        True
2951        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
2952        True
2953        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
2954        True
2955        >>> tracker.zakatable('account1', False)
2956        False
2957        ```
2958        """
2959        if self.account_exists(account):
2960            if status is None:
2961                return self.__vault.account[account].zakatable
2962            self.__vault.account[account].zakatable = status
2963            return status
2964        return False

Check or set the zakatable status of a specific account.

Parameters:

  • account (AccountID): The account reference.
  • 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 subtract( self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = '1', created_time_ns: Optional[Timestamp] = None, debug: bool = False) -> SubtractReport:
2966    def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = AccountID('1'),
2967            created_time_ns: Optional[Timestamp] = None,
2968            debug: bool = False) \
2969            -> SubtractReport:
2970        """
2971        Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance,
2972        the remaining amount will be transferred to a new transaction with a negative value.
2973
2974        Parameters:
2975        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
2976        - desc (str, optional): A description for the transaction. Defaults to an empty string.
2977        - account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'.
2978        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
2979                                           If not provided, the current timestamp will be used.
2980        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
2981
2982        Returns:
2983        - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
2984
2985        Raises:
2986        - ValueError: The unscaled_value should be greater than zero.
2987        - ValueError: The created_time_ns should be greater than zero.
2988        - ValueError: The box transaction happened again in the same nanosecond time.
2989        - ValueError: The log transaction happened again in the same nanosecond time.
2990        """
2991        if debug:
2992            print('sub', f'debug={debug}')
2993        account = AccountID(account)
2994        if unscaled_value <= 0:
2995            raise ValueError('The unscaled_value should be greater than zero.')
2996        if created_time_ns is None:
2997            created_time_ns = Time.time()
2998        if created_time_ns <= 0:
2999            raise ValueError('The created should be greater than zero.')
3000        no_lock = self.nolock()
3001        lock = self.__lock()
3002        self.__track(0, '', account)
3003        value = self.scale(unscaled_value)
3004        self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
3005        ids = sorted(self.__vault.account[account].box.keys())
3006        limit = len(ids) + 1
3007        target = value
3008        if debug:
3009            print('ids', ids)
3010        ages = SubtractAges()
3011        for i in range(-1, -limit, -1):
3012            if target == 0:
3013                break
3014            j = ids[i]
3015            if debug:
3016                print('i', i, 'j', j)
3017            rest = self.__vault.account[account].box[j].rest
3018            if rest >= target:
3019                self.__vault.account[account].box[j].rest -= target
3020                self.__step(Action.SUBTRACT, account, ref=j, value=target)
3021                ages.append(SubtractAge(box_ref=j, total=target))
3022                target = 0
3023                break
3024            elif target > rest > 0:
3025                chunk = rest
3026                target -= chunk
3027                self.__vault.account[account].box[j].rest = 0
3028                self.__step(Action.SUBTRACT, account, ref=j, value=chunk)
3029                ages.append(SubtractAge(box_ref=j, total=chunk))
3030        if target > 0:
3031            self.__track(
3032                unscaled_value=self.unscale(-target),
3033                desc=desc,
3034                account=account,
3035                logging=False,
3036                created_time_ns=created_time_ns,
3037            )
3038            ages.append(SubtractAge(box_ref=created_time_ns, total=target))
3039        if no_lock:
3040            assert lock is not None
3041            self.free(lock)
3042        return SubtractReport(
3043            log_ref=created_time_ns,
3044            ages=ages,
3045        )

Subtracts a specified value from an account's balance, 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.

Parameters:

  • unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
  • desc (str, optional): A description for the transaction. Defaults to an empty string.
  • account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'.
  • created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
  • debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.

Returns:

  • SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.

Raises:

  • ValueError: The unscaled_value should be greater than zero.
  • ValueError: The created_time_ns should be greater than zero.
  • 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, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '', created_time_ns: Optional[Timestamp] = None, debug: bool = False) -> Optional[TransferReport]:
3047    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '',
3048                 created_time_ns: Optional[Timestamp] = None,
3049                 debug: bool = False) -> Optional[TransferReport]:
3050        """
3051        Transfers a specified value from one account to another.
3052
3053        Parameters:
3054        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
3055        - from_account (AccountID): The account reference from which the value will be transferred.
3056        - to_account (AccountID): The account reference to which the value will be transferred.
3057        - desc (str, optional): A description for the transaction. Defaults to an empty string.
3058        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
3059        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
3060
3061        Returns:
3062        - Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer.
3063
3064        Raises:
3065        - ValueError: Transfer to the same account is forbidden.
3066        - ValueError: The created_time_ns should be greater than zero.
3067        - ValueError: The box transaction happened again in the same nanosecond time.
3068        - ValueError: The log transaction happened again in the same nanosecond time.
3069        """
3070        if debug:
3071            print('transfer', f'debug={debug}')
3072        from_account = AccountID(from_account)
3073        to_account = AccountID(to_account)
3074        if from_account == to_account:
3075            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
3076        if unscaled_amount <= 0:
3077            return None
3078        if created_time_ns is None:
3079            created_time_ns = Time.time()
3080        if created_time_ns <= 0:
3081            raise ValueError('The created should be greater than zero.')
3082        no_lock = self.nolock()
3083        lock = self.__lock()
3084        subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug)
3085        source_exchange = self.exchange(from_account, created_time_ns)
3086        target_exchange = self.exchange(to_account, created_time_ns)
3087
3088        if debug:
3089            print('ages', subtract_report.ages)
3090
3091        transfer_report = TransferReport()
3092        for subtract in subtract_report.ages:
3093            times = TransferTimes()
3094            age = subtract.box_ref
3095            value = subtract.total
3096            assert source_exchange.rate is not None
3097            assert target_exchange.rate is not None
3098            target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate))
3099            if debug:
3100                print('target_amount', target_amount)
3101            # Perform the transfer
3102            if self.box_exists(to_account, age):
3103                if debug:
3104                    print('box_exists', age)
3105                capital = self.__vault.account[to_account].box[age].capital
3106                rest = self.__vault.account[to_account].box[age].rest
3107                if debug:
3108                    print(
3109                        f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3110                selected_age = age
3111                if rest + target_amount > capital:
3112                    self.__vault.account[to_account].box[age].capital += target_amount
3113                    selected_age = Time.time()
3114                self.__vault.account[to_account].box[age].rest += target_amount
3115                self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
3116                y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
3117                              created_time_ns=None, ref=None, debug=debug)
3118                times.append(TransferTime(box_ref=age, log_ref=y))
3119                continue
3120            if debug:
3121                print(
3122                    f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3123            box_ref = self.__track(
3124                unscaled_value=self.unscale(int(target_amount)),
3125                desc=desc,
3126                account=to_account,
3127                logging=True,
3128                created_time_ns=age,
3129                debug=debug,
3130            )
3131            transfer_report.append(TransferRecord(
3132                box_ref=box_ref,
3133                times=times,
3134            ))
3135        if no_lock:
3136            assert lock is not None
3137            self.free(lock)
3138        return transfer_report

Transfers a specified value from one account to another.

Parameters:

  • unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
  • from_account (AccountID): The account reference from which the value will be transferred.
  • to_account (AccountID): The account reference to which the value will be transferred.
  • desc (str, optional): A description for the transaction. Defaults to an empty string.
  • created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
  • debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.

Returns:

  • Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer.

Raises:

  • ValueError: Transfer to the same account is forbidden.
  • ValueError: The created_time_ns should be greater than zero.
  • 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, unscaled_nisab: Union[float, int, decimal.Decimal, NoneType] = None, debug: bool = False, created_time_ns: Optional[Timestamp] = None, cycle: Optional[float] = None) -> ZakatReport:
3140    def check(self,
3141              silver_gram_price: float,
3142              unscaled_nisab: Optional[float | int | decimal.Decimal] = None,
3143              debug: bool = False,
3144              created_time_ns: Optional[Timestamp] = None,
3145              cycle: Optional[float] = None) -> ZakatReport:
3146        """
3147        Check the eligibility for Zakat based on the given parameters.
3148
3149        Parameters:
3150        - silver_gram_price (float): The price of a gram of silver.
3151        - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat.
3152                        If not provided, it will be calculated based on the silver_gram_price.
3153        - debug (bool, optional): Flag to enable debug mode.
3154        - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
3155        - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
3156
3157        Returns:
3158        - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat,
3159            a list of brief statistics, and a dictionary containing the Zakat plan.
3160        """
3161        if debug:
3162            print('check', f'debug={debug}')
3163        before_parameters = {
3164            "silver_gram_price": silver_gram_price,
3165            "unscaled_nisab": unscaled_nisab,
3166            "debug": debug,
3167            "created_time_ns": created_time_ns,
3168            "cycle": cycle,
3169        }
3170        if created_time_ns is None:
3171            created_time_ns = Time.time()
3172        if cycle is None:
3173            cycle = ZakatTracker.TimeCycle()
3174        if unscaled_nisab is None:
3175            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
3176        nisab = self.scale(unscaled_nisab)
3177        plan: dict[AccountID, list[BoxPlan]] = {}
3178        summary = ZakatSummary()
3179        below_nisab = 0
3180        valid = False
3181        after_parameters = {
3182            "silver_gram_price": silver_gram_price,
3183            "unscaled_nisab": unscaled_nisab,
3184            "debug": debug,
3185            "created_time_ns": created_time_ns,
3186            "cycle": cycle,
3187        }
3188        if debug:
3189            print('exchanges', self.exchanges())
3190        for x in self.__vault.account:
3191            if not self.zakatable(x):
3192                continue
3193            _box = self.__vault.account[x].box
3194            _log = self.__vault.account[x].log
3195            limit = len(_box) + 1
3196            ids = sorted(self.__vault.account[x].box.keys())
3197            for i in range(-1, -limit, -1):
3198                j = ids[i]
3199                rest = float(_box[j].rest)
3200                if rest <= 0:
3201                    continue
3202                exchange = self.exchange(x, created_time_ns=Time.time())
3203                assert exchange.rate is not None
3204                rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1)
3205                summary.num_wealth_items += 1
3206                summary.total_wealth += rest
3207                epoch = (created_time_ns - j) / cycle
3208                if debug:
3209                    print(f'Epoch: {epoch}', _box[j])
3210                if _box[j].zakat.last > 0:
3211                    epoch = (created_time_ns - _box[j].zakat.last) / cycle
3212                if debug:
3213                    print(f'Epoch: {epoch}')
3214                epoch = math.floor(epoch)
3215                if debug:
3216                    print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch)
3217                if epoch == 0:
3218                    continue
3219                if debug:
3220                    print('Epoch - PASSED')
3221                summary.num_zakatable_items += 1
3222                summary.total_zakatable_amount += rest
3223                is_nisab = rest >= nisab
3224                total = 0
3225                if is_nisab:
3226                    for _ in range(epoch):
3227                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
3228                    valid = total > 0
3229                elif rest > 0:
3230                    below_nisab += rest
3231                    total = ZakatTracker.ZakatCut(float(rest))
3232                if total > 0:
3233                    if x not in plan:
3234                        plan[x] = []
3235                    summary.total_zakat_due += total
3236                    plan[x].append(BoxPlan(
3237                        below_nisab=not is_nisab,
3238                        total=total,
3239                        count=epoch,
3240                        ref=j,
3241                        box=_box[j],
3242                        log=_log[j],
3243                        exchange=exchange,
3244                    ))
3245        valid = valid or below_nisab >= nisab
3246        if debug:
3247            print(f'below_nisab({below_nisab}) >= nisab({nisab})')
3248        report = ZakatReport(
3249            created=Time.time(),
3250            valid=valid,
3251            summary=summary,
3252            plan=plan,
3253            parameters={
3254                'before': before_parameters,
3255                'after': after_parameters,
3256            },
3257        )
3258        self.__vault.cache.zakat = report if valid else None
3259        return report

Check the eligibility for Zakat based on the given parameters.

Parameters:

  • silver_gram_price (float): The price of a gram of silver.
  • unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat. If not provided, it will be calculated based on the silver_gram_price.
  • debug (bool, optional): Flag to enable debug mode.
  • created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
  • cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().

Returns:

  • ZakatReport: 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, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
3261    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
3262        """
3263        Build payment parts for the Zakat distribution.
3264
3265        Parameters:
3266        - scaled_demand (int): The total demand for payment in local currency.
3267        - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
3268
3269        Returns:
3270        - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure:
3271        {
3272            'account': {
3273                'account_id': {'balance': float, 'rate': float, 'part': float},
3274                ...
3275            },
3276            'exceed': bool,
3277            'demand': int,
3278            'total': float,
3279        }
3280        """
3281        total = 0.0
3282        parts = PaymentParts(
3283            account={},
3284            exceed=False,
3285            demand=int(round(scaled_demand)),
3286            total=0,
3287        )
3288        for x, y in self.accounts().items():
3289            if positive_only and y.balance <= 0:
3290                continue
3291            total += float(y.balance)
3292            exchange = self.exchange(x)
3293            parts.account[x] = AccountPaymentPart(balance=y.balance, rate=exchange.rate, part=0)
3294        parts.total = total
3295        return parts

Build payment parts for the Zakat distribution.

Parameters:

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

Returns:

  • PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure: { 'account': { 'account_id': {'balance': float, 'rate': float, 'part': float}, ... }, 'exceed': bool, 'demand': int, 'total': float, }
@staticmethod
def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int:
3297    @staticmethod
3298    def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int:
3299        """
3300        Checks the validity of payment parts.
3301
3302        Parameters:
3303        - parts (dict[str, PaymentParts): A dictionary containing payment parts information.
3304        - debug (bool, optional): Flag to enable debug mode.
3305
3306        Returns:
3307        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
3308
3309        Error Codes:
3310        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
3311        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
3312        3: 'part' value in parts['account'][x] is less than 0.
3313        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
3314        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
3315        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
3316        """
3317        if debug:
3318            print('check_payment_parts', f'debug={debug}')
3319        # for i in ['demand', 'account', 'total', 'exceed']:
3320        #     if i not in parts:
3321        #         return 1
3322        exceed = parts.exceed
3323        # for j in ['balance', 'rate', 'part']:
3324        #     if j not in parts.account[x]:
3325        #         return 2
3326        for x in parts.account:
3327            if parts.account[x].part < 0:
3328                return 3
3329            if not exceed and parts.account[x].balance <= 0:
3330                return 4
3331        demand = parts.demand
3332        z = 0.0
3333        for _, y in parts.account.items():
3334            if not exceed and y.part > y.balance:
3335                return 5
3336            z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0)
3337        z = round(z, 2)
3338        demand = round(demand, 2)
3339        if debug:
3340            print('check_payment_parts', f'z = {z}, demand = {demand}')
3341            print('check_payment_parts', type(z), type(demand))
3342            print('check_payment_parts', z != demand)
3343            print('check_payment_parts', str(z) != str(demand))
3344        if z != demand and str(z) != str(demand):
3345            return 6
3346        return 0

Checks the validity of payment parts.

Parameters:

  • parts (dict[str, PaymentParts): A dictionary containing payment parts information.
  • debug (bool, optional): 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: ZakatReport, parts: Optional[PaymentParts] = None, debug: bool = False) -> bool:
3348    def zakat(self, report: ZakatReport,
3349        parts: Optional[PaymentParts] = None, debug: bool = False) -> bool:
3350        """
3351        Perform Zakat calculation based on the given report and optional parts.
3352
3353        Parameters:
3354        - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
3355        - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
3356        - debug (bool, optional): A flag indicating whether to print debug information.
3357
3358        Returns:
3359        - bool: True if the zakat calculation is successful, False otherwise.
3360
3361        Raises:
3362        - AssertionError: Bad Zakat report, call `check` first then call `zakat`.
3363        """
3364        if debug:
3365            print('zakat', f'debug={debug}')
3366        if not report.valid:
3367            return report.valid
3368        assert report.plan
3369        parts_exist = parts is not None
3370        if parts_exist:
3371            if self.check_payment_parts(parts, debug=debug) != 0:
3372                return False
3373        if debug:
3374            print('######### zakat #######')
3375            print('parts_exist', parts_exist)
3376        assert report == self.__vault.cache.zakat, "Bad Zakat report, call `check` first then call `zakat`"
3377        no_lock = self.nolock()
3378        lock = self.__lock()
3379        report_time = Time.time()
3380        self.__vault.report[report_time] = report
3381        self.__step(Action.REPORT, ref=report_time)
3382        created_time_ns = Time.time()
3383        for x in report.plan:
3384            target_exchange = self.exchange(x)
3385            if debug:
3386                print(report.plan[x])
3387                print('-------------')
3388                print(self.__vault.account[x].box)
3389            if debug:
3390                print('plan[x]', report.plan[x])
3391            for plan in report.plan[x]:
3392                j = plan.ref
3393                if debug:
3394                    print('j', j)
3395                assert j
3396                self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].zakat.last,
3397                           key='last',
3398                           math_operation=MathOperation.EQUAL)
3399                self.__vault.account[x].box[j].zakat.last = created_time_ns
3400                assert target_exchange.rate is not None
3401                amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate))
3402                self.__vault.account[x].box[j].zakat.total += amount
3403                self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
3404                           math_operation=MathOperation.ADDITION)
3405                self.__vault.account[x].box[j].zakat.count += plan.count
3406                self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count',
3407                           math_operation=MathOperation.ADDITION)
3408                if not parts_exist:
3409                    try:
3410                        self.__vault.account[x].box[j].rest -= amount
3411                    except TypeError:
3412                        self.__vault.account[x].box[j].rest -= decimal.Decimal(amount)
3413                    # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
3414                    #            math_operation=MathOperation.SUBTRACTION)
3415                    self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug)
3416        if parts_exist:
3417            for account, part in parts.account.items():
3418                if part.part == 0:
3419                    continue
3420                if debug:
3421                    print('zakat-part', account, part.rate)
3422                target_exchange = self.exchange(account)
3423                assert target_exchange.rate is not None
3424                amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate)
3425                unscaled_amount = self.unscale(int(amount))
3426                if unscaled_amount <= 0:
3427                    if debug:
3428                        print(f"The amount({unscaled_amount:.20f}) it was {amount:.20f} should be greater tha zero.")
3429                    continue
3430                self.subtract(
3431                    unscaled_value=unscaled_amount,
3432                    desc='zakat-part-دفعة-زكاة',
3433                    account=account,
3434                    debug=debug,
3435                )
3436        if no_lock:
3437            assert lock is not None
3438            self.free(lock)
3439        self.__vault.cache.zakat = None
3440        return True

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

Parameters:

  • report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
  • parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
  • debug (bool, optional): A flag indicating whether to print debug information.

Returns:

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

Raises:

  • AssertionError: Bad Zakat report, call check first then call zakat.
@staticmethod
def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
3442    @staticmethod
3443    def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
3444        """Splits a string at the last occurrence of a given symbol.
3445    
3446        Parameters:
3447        - data (str): The input string.
3448        - symbol (str): The symbol to split at.
3449    
3450        Returns:
3451        - tuple[str, str]: A tuple containing two strings, the part before the last symbol and
3452            the part after the last symbol. If the symbol is not found, returns (data, "").
3453        """
3454        last_symbol_index = data.rfind(symbol)
3455    
3456        if last_symbol_index != -1:
3457            before_symbol = data[:last_symbol_index]
3458            after_symbol = data[last_symbol_index + len(symbol):]
3459            return before_symbol, after_symbol
3460        return data, ""

Splits a string at the last occurrence of a given symbol.

Parameters:

  • data (str): The input string.
  • symbol (str): The symbol to split at.

Returns:

  • tuple[str, str]: A tuple containing two strings, the part before the last symbol and the part after the last symbol. If the symbol is not found, returns (data, "").
def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool:
3462    def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool:
3463        """
3464        Saves the ZakatTracker's current state to a json file.
3465
3466        This method serializes the internal data (`__vault`).
3467
3468        Parameters:
3469        - path (str, optional): File path for saving. Defaults to a predefined location.
3470        - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.
3471
3472        Returns:
3473        - bool: True if the save operation is successful, False otherwise.
3474        """
3475        if path is None:
3476            path = self.path()
3477        # first save in tmp file
3478        temp = f'{path}.tmp'
3479        try:
3480            with open(temp, 'w', encoding='utf-8') as stream:
3481                data = json.dumps(self.__vault, cls=JSONEncoder)
3482                stream.write(data)
3483                if hash_required:
3484                    hashed = self.hash_data(data.encode())
3485                    stream.write(f'//{hashed}')
3486            # then move tmp file to original location
3487            shutil.move(temp, path)
3488            return True
3489        except (IOError, OSError) as e:
3490            print(f'Error saving file: {e}')
3491            if os.path.exists(temp):
3492                os.remove(temp)
3493            return False

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

This method serializes the internal data (__vault).

Parameters:

  • path (str, optional): File path for saving. Defaults to a predefined location.
  • hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.

Returns:

  • bool: True if the save operation is successful, False otherwise.
@staticmethod
def load_vault_from_json(json_string: str) -> Vault:
3495    @staticmethod
3496    def load_vault_from_json(json_string: str) -> Vault:
3497        """Loads a Vault dataclass from a JSON string."""
3498        data = json.loads(json_string)
3499
3500        vault = Vault()
3501
3502        # Load Accounts
3503        for account_reference, account_data in data.get("account", {}).items():
3504            account_reference = AccountID(account_reference)
3505            box_data = account_data.get('box', {})
3506            box = {
3507                Timestamp(ts): Box(
3508                    capital=box_data[str(ts)]["capital"],
3509                    rest=box_data[str(ts)]["rest"],
3510                    zakat=BoxZakat(**box_data[str(ts)]["zakat"]),
3511                )
3512                for ts in box_data
3513            }
3514
3515            log_data = account_data.get('log', {})
3516            log = {Timestamp(ts): Log(
3517                value=log_data[str(ts)]['value'],
3518                desc=log_data[str(ts)]['desc'],
3519                ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None,
3520                file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()},
3521            ) for ts in log_data}
3522
3523            vault.account[account_reference] = Account(
3524                balance=account_data["balance"],
3525                created=Timestamp(account_data["created"]),
3526                name=account_data.get("name", ""),
3527                box=box,
3528                count=account_data.get("count", 0),
3529                log=log,
3530                hide=account_data.get("hide", False),
3531                zakatable=account_data.get("zakatable", True),
3532            )
3533
3534        # Load Exchanges
3535        for account_reference, exchange_data in data.get("exchange", {}).items():
3536            account_reference = AccountID(account_reference)
3537            vault.exchange[account_reference] = {}
3538            for timestamp, exchange_details in exchange_data.items():
3539                vault.exchange[account_reference][Timestamp(timestamp)] = Exchange(
3540                    rate=exchange_details.get("rate"),
3541                    description=exchange_details.get("description"),
3542                    time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None,
3543                )
3544
3545        # Load History
3546        for timestamp, history_dict in data.get("history", {}).items():
3547            vault.history[Timestamp(timestamp)] = {}
3548            for history_key, history_data in history_dict.items():
3549                vault.history[Timestamp(timestamp)][Timestamp(history_key)] = History(
3550                    action=Action(history_data["action"]),
3551                    account=AccountID(history_data["account"]) if history_data.get("account") is not None else None,
3552                    ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None,
3553                    file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None,
3554                    key=history_data.get("key"),
3555                    value=history_data.get("value"),
3556                    math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None,
3557                )
3558
3559        # Load Lock
3560        vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None
3561
3562        # Load Report
3563        for timestamp, report_data in data.get("report", {}).items():
3564            zakat_plan: dict[AccountID, list[BoxPlan]] = {}
3565            for account_reference, box_plans in report_data.get("plan", {}).items():
3566                account_reference = AccountID(account_reference)
3567                zakat_plan[account_reference] = []
3568                for box_plan_data in box_plans:
3569                    zakat_plan[account_reference].append(BoxPlan(
3570                        box=Box(
3571                            capital=box_plan_data["box"]["capital"],
3572                            rest=box_plan_data["box"]["rest"],
3573                            zakat=BoxZakat(**box_plan_data["box"]["zakat"]),
3574                        ),
3575                        log=Log(**box_plan_data["log"]),
3576                        exchange=Exchange(**box_plan_data["exchange"]),
3577                        below_nisab=box_plan_data["below_nisab"],
3578                        total=box_plan_data["total"],
3579                        count=box_plan_data["count"],
3580                        ref=Timestamp(box_plan_data["ref"]),
3581                    ))
3582
3583            vault.report[Timestamp(timestamp)] = ZakatReport(
3584                created=report_data["created"],
3585                valid=report_data["valid"],
3586                summary=ZakatSummary(**report_data["summary"]),
3587                plan=zakat_plan,
3588                parameters=report_data["parameters"],
3589            )
3590
3591        # Load Cache
3592        vault.cache = Cache()
3593        cache_data = data.get("cache", {})
3594        if "zakat" in cache_data:
3595            cache_zakat_data = cache_data.get("zakat", {})
3596            if cache_zakat_data:
3597                zakat_plan: dict[AccountID, list[BoxPlan]] = {}
3598                for account_reference, box_plans in cache_zakat_data.get("plan", {}).items():
3599                    account_reference = AccountID(account_reference)
3600                    zakat_plan[account_reference] = []
3601                    for box_plan_data in box_plans:
3602                        zakat_plan[account_reference].append(BoxPlan(
3603                            box=Box(
3604                                capital=box_plan_data["box"]["capital"],
3605                                rest=box_plan_data["box"]["rest"],
3606                                zakat=BoxZakat(**box_plan_data["box"]["zakat"]),
3607                            ),
3608                            log=Log(**box_plan_data["log"]),
3609                            exchange=Exchange(**box_plan_data["exchange"]),
3610                            below_nisab=box_plan_data["below_nisab"],
3611                            total=box_plan_data["total"],
3612                            count=box_plan_data["count"],
3613                            ref=Timestamp(box_plan_data["ref"]),
3614                        ))
3615
3616                vault.cache.zakat = ZakatReport(
3617                    created=cache_zakat_data["created"],
3618                    valid=cache_zakat_data["valid"],
3619                    summary=ZakatSummary(**cache_zakat_data["summary"]),
3620                    plan=zakat_plan,
3621                    parameters=cache_zakat_data["parameters"],
3622                )
3623
3624        return vault

Loads a Vault dataclass from a JSON string.

def load( self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool:
3626    def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool:
3627        """
3628        Load the current state of the ZakatTracker object from a json file.
3629
3630        Parameters:
3631        - path (str, optional): The path where the json file is located. If not provided, it will use the default path.
3632        - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
3633        - debug (bool, optional): Flag to enable debug mode.
3634
3635        Returns:
3636        - bool: True if the load operation is successful, False otherwise.
3637        """
3638        if path is None:
3639            path = self.path()
3640        try:
3641            if os.path.exists(path):
3642                with open(path, 'r', encoding='utf-8') as stream:
3643                    file = stream.read()
3644                    data, hashed = self.split_at_last_symbol(file, '//')
3645                    if hash_required:
3646                        assert hashed
3647                        if debug:
3648                            print('[debug-load]', hashed)
3649                        new_hash = self.hash_data(data.encode())
3650                        if debug:
3651                            print('[debug-load]', new_hash)
3652                        assert hashed == new_hash, "Hash verification failed. File may be corrupted."
3653                    self.__vault = self.load_vault_from_json(data)
3654                return True
3655            else:
3656                print(f'File not found: {path}')
3657                return False
3658        except (IOError, OSError) as e:
3659            print(f'Error loading file: {e}')
3660            return False

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

Parameters:

  • path (str, optional): The path where the json file is located. If not provided, it will use the default path.
  • hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
  • debug (bool, optional): Flag to enable debug mode.

Returns:

  • bool: True if the load operation is successful, False otherwise.
def import_csv_cache_path(self):
3662    def import_csv_cache_path(self):
3663        """
3664        Generates the cache file path for imported CSV data.
3665
3666        This function constructs the file path where cached data from CSV imports
3667        will be stored. The cache file is a json file (.json extension) appended
3668        to the base path of the object.
3669
3670        Parameters:
3671        None
3672
3673        Returns:
3674        - str: The full path to the import CSV cache file.
3675
3676        Example:
3677        ```bash
3678        >>> obj = ZakatTracker('/data/reports')
3679        >>> obj.import_csv_cache_path()
3680        '/data/reports.import_csv.json'
3681        ```
3682        """
3683        path = str(self.path())
3684        ext = self.ext()
3685        ext_len = len(ext)
3686        if path.endswith(f'.{ext}'):
3687            path = path[:-ext_len - 1]
3688        _, filename = os.path.split(path + f'.import_csv.{ext}')
3689        return self.base_path(filename)

Generates the cache file path for imported CSV data.

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

Parameters: None

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.json'
@staticmethod
def get_transaction_csv_headers() -> list[str]:
3691    @staticmethod
3692    def get_transaction_csv_headers() -> list[str]:
3693        """
3694        Returns a list of strings representing the headers for a transaction CSV file.
3695
3696        The headers include:
3697        - account: The account associated with the transaction.
3698        - desc: A description of the transaction.
3699        - value: The monetary value of the transaction.
3700        - date: The date of the transaction.
3701        - rate: The applicable rate (if any) for the transaction.
3702        - reference: An optional reference number or identifier for the transaction.
3703
3704        Returns:
3705        - list[str]: A list containing the CSV header strings.
3706        """
3707        return [
3708            "account",
3709            "desc",
3710            "value",
3711            "date",
3712            "rate",
3713            "reference",
3714        ]

Returns a list of strings representing the headers for a transaction CSV file.

The headers include:

  • account: The account associated with the transaction.
  • desc: A description of the transaction.
  • value: The monetary value of the transaction.
  • date: The date of the transaction.
  • rate: The applicable rate (if any) for the transaction.
  • reference: An optional reference number or identifier for the transaction.

Returns:

  • list[str]: A list containing the CSV header strings.
def import_csv( self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> ImportReport:
3716    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> ImportReport:
3717        """
3718        The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system.
3719
3720        Parameters:
3721        - path (str, optional): The path to the CSV file. Default is 'file.csv'.
3722        - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
3723        - debug (bool, optional): A flag indicating whether to print debug information.
3724
3725        Returns:
3726        - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache,
3727                and a dictionary of bad transactions.
3728
3729        Notes:
3730        * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
3731                                    are appropriate for the currency pairs involved in the conversions.
3732        * The exchange rate for each account is based on the last encountered transaction rate that is not equal
3733            to 1.0 or the previous rate for that account.
3734        * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
3735            transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and
3736            `zakat` operations.
3737
3738        Example:
3739            The CSV file should have the following format, rate and reference are optionals per transaction:
3740            account, desc, value, date, rate, reference
3741            For example:
3742            safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554
3743        """
3744        if debug:
3745            print('import_csv', f'debug={debug}')
3746        cache: list[int] = []
3747        try:
3748            if not self.memory_mode():
3749                with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream:
3750                    cache = json.load(stream)
3751        except Exception as e:
3752            if debug:
3753                print(e)
3754        date_formats = [
3755            '%Y-%m-%d %H:%M:%S.%f',
3756            '%Y-%m-%dT%H:%M:%S.%f',
3757            '%Y-%m-%dT%H%M%S.%f',
3758            '%Y-%m-%d',
3759        ]
3760        statistics = ImportStatistics(0, 0, 0)
3761        data: dict[int, list[CSVRecord]] = {}
3762        with open(path, newline='', encoding='utf-8') as f:
3763            i = 0
3764            for row in csv.reader(f, delimiter=','):
3765                if debug:
3766                    print(f"csv_row({i})", row, type(row))
3767                if row == self.get_transaction_csv_headers():
3768                    continue
3769                i += 1
3770                hashed = hash(tuple(row))
3771                if hashed in cache:
3772                    statistics.found += 1
3773                    continue
3774                account = row[0]
3775                desc = row[1]
3776                value = float(row[2])
3777                rate = 1.0
3778                reference = ''
3779                if row[4:5]: # Empty list if index is out of range
3780                    rate = float(row[4])
3781                if row[5:6]:
3782                    reference = row[5]
3783                date: int = 0
3784                for time_format in date_formats:
3785                    try:
3786                        date_str = row[3]
3787                        if "." not in date_str:
3788                            date_str += ".000000"
3789                        date = Time.time(datetime.datetime.strptime(date_str, time_format))
3790                        break
3791                    except Exception as e:
3792                        if debug:
3793                            print(e)
3794                record = CSVRecord(
3795                    index=i,
3796                    account=account,
3797                    desc=desc,
3798                    value=value,
3799                    date=date,
3800                    rate=rate,
3801                    reference=reference,
3802                    hashed=hashed,
3803                    error='',
3804                )
3805                if date <= 0:
3806                    record.error = 'invalid date'
3807                    statistics.bad += 1
3808                if value == 0:
3809                    record.error = 'invalid value'
3810                    statistics.bad += 1
3811                    continue
3812                if date not in data:
3813                    data[date] = []
3814                data[date].append(record)
3815
3816        if debug:
3817            print('import_csv', len(data))
3818
3819        if statistics.bad > 0:
3820            return ImportReport(
3821                statistics=statistics,
3822                bad=[
3823                    item
3824                    for sublist in data.values()
3825                    for item in sublist
3826                    if item.error
3827                ],
3828            )
3829
3830        no_lock = self.nolock()
3831        lock = self.__lock()
3832        names = self.names()
3833
3834        # sync accounts
3835        if debug:
3836            print('before-names', names, len(names))
3837        for date, rows in sorted(data.items()):
3838            new_rows: list[CSVRecord] = []
3839            for row in rows:
3840                if row.account not in names:
3841                    account_id = self.create_account(row.account)
3842                    names[row.account] = account_id
3843                account_id = names[row.account]
3844                assert account_id
3845                row.account = account_id
3846                new_rows.append(row)
3847            assert new_rows
3848            assert date in data
3849            data[date] = new_rows
3850        if debug:
3851            print('after-names', names, len(names))
3852            assert names == self.names()
3853
3854        # do ops
3855        for date, rows in sorted(data.items()):
3856            try:
3857                def process(x: CSVRecord):
3858                    x.value = self.unscale(
3859                        x.value,
3860                        decimal_places=scale_decimal_places,
3861                    ) if scale_decimal_places > 0 else x.value
3862                    if x.rate > 0:
3863                        self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate)
3864                    if x.value > 0:
3865                        self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3866                    elif x.value < 0:
3867                        self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3868                    return x.hashed
3869                len_rows = len(rows)
3870                # If records are found at the same time with different accounts in the same amount
3871                # (one positive and the other negative), this indicates it is a transfer.
3872                if len_rows > 2 or len_rows == 1:
3873                    for row in rows:
3874                        hashed = process(row)
3875                        assert hashed not in cache
3876                        cache.append(hashed)
3877                        statistics.created += 1
3878                    continue
3879                x1 = rows[0]
3880                x2 = rows[1]
3881                if x1.account == x2.account:
3882                    continue
3883                    # raise Exception(f'invalid transfer')
3884                # not transfer - same time - normal ops
3885                if abs(x1.value) != abs(x2.value) and x1.date == x2.date:
3886                    rows[1].date += 1
3887                    for row in rows:
3888                        hashed = process(row)
3889                        assert hashed not in cache
3890                        cache.append(hashed)
3891                        statistics.created += 1
3892                    continue
3893                if x1.rate > 0:
3894                    self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate)
3895                if x2.rate > 0:
3896                    self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate)
3897                x1.value = self.unscale(
3898                    x1.value,
3899                    decimal_places=scale_decimal_places,
3900                ) if scale_decimal_places > 0 else x1.value
3901                x2.value = self.unscale(
3902                    x2.value,
3903                    decimal_places=scale_decimal_places,
3904                ) if scale_decimal_places > 0 else x2.value
3905                # just transfer
3906                values = {
3907                    x1.value: x1.account,
3908                    x2.value: x2.account,
3909                }
3910                if debug:
3911                    print('values', values)
3912                if len(values) <= 1:
3913                    continue
3914                self.transfer(
3915                    unscaled_amount=abs(x1.value),
3916                    from_account=values[min(values.keys())],
3917                    to_account=values[max(values.keys())],
3918                    desc=x1.desc,
3919                    created_time_ns=x1.date,
3920                )
3921            except Exception as e:
3922                for row in rows:
3923                    _tuple = tuple()
3924                    for field in row:
3925                        _tuple += (field,)
3926                    _tuple += (e,)
3927                    bad[i] = _tuple
3928                break
3929        if not self.memory_mode():
3930            with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream:
3931                stream.write(json.dumps(cache))
3932        if no_lock:
3933            assert lock is not None
3934            self.free(lock)
3935        report = ImportReport(
3936            statistics=statistics,
3937            bad=[
3938                item
3939                for sublist in data.values()
3940                for item in sublist
3941                if item.error
3942            ],
3943        )
3944        if debug:
3945            debug_path = f'{self.import_csv_cache_path()}.debug.json'
3946            with open(debug_path, 'w', encoding='utf-8') as file:
3947                json.dump(report, file, indent=4, cls=JSONEncoder)
3948                print(f'generated debug report @ `{debug_path}`...')
3949        return report

The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system.

Parameters:

  • path (str, optional): The path to the CSV file. Default is 'file.csv'.
  • scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
  • debug (bool, optional): A flag indicating whether to print debug information.

Returns:

  • ImportReport: A dataclass 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 transfer, check and zakat operations.

Example: The CSV file should have the following format, rate and reference are optionals per transaction: account, desc, value, date, rate, reference For example: safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554

@staticmethod
def human_readable_size(size: float, decimal_places: int = 2) -> str:
3955    @staticmethod
3956    def human_readable_size(size: float, decimal_places: int = 2) -> str:
3957        """
3958        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
3959
3960        This function iterates through progressively larger units of information
3961        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
3962        range that can be expressed with a reasonable number before the unit.
3963
3964        Parameters:
3965        - size (float): The size in bytes to convert.
3966        - decimal_places (int, optional): The number of decimal places to display
3967            in the result. Defaults to 2.
3968
3969        Returns:
3970        - str: A string representation of the size in a human-readable format,
3971            rounded to the specified number of decimal places. For example:
3972                - '1.50 KB' (1536 bytes)
3973                - '23.00 MB' (24117248 bytes)
3974                - '1.23 GB' (1325899906 bytes)
3975        """
3976        if type(size) not in (float, int):
3977            raise TypeError('size must be a float or integer')
3978        if type(decimal_places) != int:
3979            raise TypeError('decimal_places must be an integer')
3980        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
3981            if size < 1024.0:
3982                break
3983            size /= 1024.0
3984        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: Optional[set] = None) -> float:
3986    @staticmethod
3987    def get_dict_size(obj: dict, seen: Optional[set] = None) -> float:
3988        """
3989        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
3990
3991        This function traverses the dictionary structure, accounting for the size of keys, values,
3992        and any nested objects. It handles various data types commonly found in dictionaries
3993        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
3994        of circular references.
3995
3996        Parameters:
3997        - obj (dict): The dictionary whose size is to be calculated.
3998        - seen (set, optional): A set used internally to track visited objects
3999                             and avoid circular references. Defaults to None.
4000
4001        Returns:
4002         - float: An approximate size of the dictionary and its contents in bytes.
4003
4004        Notes:
4005        - This function is a method of the `ZakatTracker` class and is likely used to
4006          estimate the memory footprint of data structures relevant to Zakat calculations.
4007        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
4008          not account for all memory overhead depending on the Python implementation.
4009        - Circular references are handled to prevent infinite recursion.
4010        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
4011        - String sizes are estimated based on character length and encoding.
4012        """
4013        size = 0
4014        if seen is None:
4015            seen = set()
4016
4017        obj_id = id(obj)
4018        if obj_id in seen:
4019            return 0
4020
4021        seen.add(obj_id)
4022        size += sys.getsizeof(obj)
4023
4024        if isinstance(obj, dict):
4025            for k, v in obj.items():
4026                size += ZakatTracker.get_dict_size(k, seen)
4027                size += ZakatTracker.get_dict_size(v, seen)
4028        elif isinstance(obj, (list, tuple, set, frozenset)):
4029            for item in obj:
4030                size += ZakatTracker.get_dict_size(item, seen)
4031        elif isinstance(obj, (int, float, complex)):  # Handle numbers
4032            pass  # Basic numbers have a fixed size, so nothing to add here
4033        elif isinstance(obj, str):  # Handle strings
4034            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
4035        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.

Notes:

  • 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 day_to_time( day: int, month: int = 6, year: int = 2024) -> Timestamp:
4037    @staticmethod
4038    def day_to_time(day: int, month: int = 6, year: int = 2024) -> Timestamp:  # افتراض أن الشهر هو يونيو والسنة 2024
4039        """
4040        Convert a specific day, month, and year into a timestamp.
4041
4042        Parameters:
4043        - day (int): The day of the month.
4044        - month (int, optional): The month of the year. Default is 6 (June).
4045        - year (int, optional): The year. Default is 2024.
4046
4047        Returns:
4048        - Timestamp: The timestamp representing the given day, month, and year.
4049
4050        Note:
4051        - This method assumes the default month and year if not provided.
4052        """
4053        return Time.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, optional): The month of the year. Default is 6 (June).
  • year (int, optional): The year. Default is 2024.

Returns:

  • Timestamp: 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:
4055    @staticmethod
4056    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
4057        """
4058        Generate a random date between two given dates.
4059
4060        Parameters:
4061        - start_date (datetime.datetime): The start date from which to generate a random date.
4062        - end_date (datetime.datetime): The end date until which to generate a random date.
4063
4064        Returns:
4065        - datetime.datetime: A random date between the start_date and end_date.
4066        """
4067        time_between_dates = end_date - start_date
4068        days_between_dates = time_between_dates.days
4069        random_number_of_days = random.randrange(days_between_dates)
4070        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:
4072    @staticmethod
4073    def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False,
4074                                 debug: bool = False) -> int:
4075        """
4076        Generate a random CSV file with specified parameters.
4077        The function generates a CSV file at the specified path with the given count of rows.
4078        Each row contains a randomly generated account, description, value, and date.
4079        The value is randomly generated between 1000 and 100000,
4080        and the date is randomly generated between 1950-01-01 and 2023-12-31.
4081        If the row number is not divisible by 13, the value is multiplied by -1.
4082
4083        Parameters:
4084        - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
4085        - count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
4086        - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
4087        - debug (bool, optional): A flag indicating whether to print debug information.
4088
4089        Returns:
4090        - int: number of generated records.
4091        """
4092        if debug:
4093            print('generate_random_csv_file', f'debug={debug}')
4094        i = 0
4095        with open(path, 'w', newline='', encoding='utf-8') as csvfile:
4096            writer = csv.writer(csvfile)
4097            writer.writerow(ZakatTracker.get_transaction_csv_headers())
4098            for i in range(count):
4099                account = f'acc-{random.randint(1, count)}'
4100                desc = f'Some text {random.randint(1, count)}'
4101                value = random.randint(1000, 100000)
4102                date = ZakatTracker.generate_random_date(
4103                    datetime.datetime(1000, 1, 1),
4104                    datetime.datetime(2023, 12, 31),
4105                ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S')
4106                if not i % 13 == 0:
4107                    value *= -1
4108                row = [account, desc, value, date]
4109                if with_rate:
4110                    rate = random.randint(1, 100) * 0.12
4111                    if debug:
4112                        print('before-append', row)
4113                    row.append(rate)
4114                    if debug:
4115                        print('after-append', row)
4116                if i % 2 == 1:
4117                    row += (Time.time(),)
4118                writer.writerow(row)
4119                i = i + 1
4120        return i

Generate a random CSV file with specified parameters. 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.

Parameters:

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

Returns:

  • int: number of generated records.
@staticmethod
def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
4122    @staticmethod
4123    def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
4124        """
4125        Creates a list of random integers whose sum does not exceed the specified maximum.
4126
4127        Parameters:
4128        - max_sum (int): The maximum allowed sum of the list elements.
4129        - min_value (int, optional): The minimum possible value for an element (inclusive).
4130        - max_value (int, optional): The maximum possible value for an element (inclusive).
4131
4132        Returns:
4133        - A list of random integers.
4134        """
4135        result = []
4136        current_sum = 0
4137
4138        while current_sum < max_sum:
4139            # Calculate the remaining space for the next element
4140            remaining_sum = max_sum - current_sum
4141            # Determine the maximum possible value for the next element
4142            next_max_value = min(remaining_sum, max_value)
4143            # Generate a random element within the allowed range
4144            next_element = random.randint(min_value, next_max_value)
4145            result.append(next_element)
4146            current_sum += next_element
4147
4148        return result

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

Parameters:

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

Returns:

  • A list of random integers.
def backup( self, folder_path: str, output_directory: str = 'compressed', debug: bool = False) -> Optional[Backup]:
4150    def backup(self, folder_path: str, output_directory: str = "compressed", debug: bool = False) -> Optional[Backup]:
4151        """
4152        Compresses a folder into a .tar.lzma archive.
4153
4154        The archive is named following a specific format:
4155        'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'.  This format
4156        is crucial for the `restore` function, so avoid renaming the files.
4157
4158        Parameters:
4159        - folder_path (str): The path to the folder to be compressed.
4160        - output_directory (str, optional): The directory to save the compressed file.
4161                                        Defaults to "compressed".
4162        - debug (bool, optional): Whether to print debug information. Default is False.
4163
4164        Returns:
4165        - Optional[Backup]: A Backup object containing the path to the created archive
4166                            and its SHA1 hash on success, None on failure.
4167        """
4168        try:
4169            os.makedirs(output_directory, exist_ok=True)
4170            now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
4171
4172            # Create a temporary tar archive in memory to calculate the hash
4173            tar_buffer = io.BytesIO()
4174            with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
4175                tar.add(folder_path, arcname=os.path.basename(folder_path))
4176            tar_buffer.seek(0)
4177            folder_hash = hashlib.sha1(tar_buffer.read()).hexdigest()
4178            output_filename = f"zakatdb_v{self.Version()}_{now}_{folder_hash}.tar.lzma"
4179            output_path = os.path.join(output_directory, output_filename)
4180
4181            # Compress the folder to the final .tar.lzma file
4182            with lzma.open(output_path, "wb") as lzma_file:
4183                tar_buffer.seek(0)  # Reset the buffer
4184                with tarfile.open(fileobj=lzma_file, mode="w") as tar:
4185                    tar.add(folder_path, arcname=os.path.basename(folder_path))
4186
4187            if debug:
4188                print(f"Folder '{folder_path}' has been compressed to '{output_path}'")
4189            return Backup(
4190                path=output_path,
4191                hash=folder_hash,
4192            )
4193        except Exception as e:
4194            print(f"Error during compression: {e}")
4195            return None

Compresses a folder into a .tar.lzma archive.

The archive is named following a specific format: 'zakatdb_v__.tar.lzma'. This format is crucial for the restore function, so avoid renaming the files.

Parameters:

  • folder_path (str): The path to the folder to be compressed.
  • output_directory (str, optional): The directory to save the compressed file. Defaults to "compressed".
  • debug (bool, optional): Whether to print debug information. Default is False.

Returns:

  • Optional[Backup]: A Backup object containing the path to the created archive and its SHA1 hash on success, None on failure.
def restore( self, tar_lzma_path: str, output_folder_path: str = 'uncompressed', debug: bool = False) -> bool:
4197    def restore(self, tar_lzma_path: str, output_folder_path: str = "uncompressed", debug: bool = False) -> bool:
4198        """
4199        Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash.
4200
4201        The SHA1 hash is extracted from the archive's filename, which must follow
4202        the format: 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'.
4203        This format is essential for successful restoration.
4204
4205        Parameters:
4206        - tar_lzma_path (str): The path to the .tar.lzma file.
4207        - output_folder_path (str, optional): The directory to extract the contents to.
4208                                            Defaults to "uncompressed".
4209        - debug (bool, optional): Whether to print debug information. Default is False.
4210        
4211        Returns:
4212        - bool: True if the restoration was successful and the hash matches, False otherwise.
4213        """
4214        try:
4215            output_folder_path = pathlib.Path(output_folder_path).resolve()
4216            os.makedirs(output_folder_path, exist_ok=True)
4217            filename = os.path.basename(tar_lzma_path)
4218            match = re.match(r"zakatdb_v([^_]+)_(\d{8}_\d{6})_([a-f0-9]{40})\.tar\.lzma", filename)
4219            if not match:
4220                if debug:
4221                    print(f"Error: Invalid filename format: '{filename}'")
4222                return False
4223
4224            expected_hash_from_filename = match.group(3)
4225
4226            with lzma.open(tar_lzma_path, "rb") as lzma_file:
4227                tar_buffer = io.BytesIO(lzma_file.read())  # Read the entire decompressed tar into memory
4228                with tarfile.open(fileobj=tar_buffer, mode="r") as tar:
4229                    tar.extractall(output_folder_path)
4230                    tar_buffer.seek(0)  # Reset buffer to calculate hash
4231                    extracted_hash = hashlib.sha1(tar_buffer.read()).hexdigest()
4232
4233            new_path = os.path.join(output_folder_path, get_first_directory_inside(output_folder_path))
4234            assert os.path.exists(os.path.join(new_path, f"db.{self.ext()}")), f"Restored db.{self.ext()} not found."
4235            if extracted_hash == expected_hash_from_filename:
4236                if debug:
4237                    print(f"'{filename}' has been successfully uncompressed to '{output_folder_path}' and hash verified from filename.")
4238                now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
4239                old_path = os.path.dirname(self.path())
4240                tmp_path = os.path.join(os.path.dirname(old_path), "tmp_restore", now)
4241                if debug:
4242                    print('[xxx] - old_path:', old_path)
4243                    print('[xxx] - tmp_path:', tmp_path)
4244                    print('[xxx] - new_path:', new_path)
4245                try:
4246                    shutil.move(old_path, tmp_path)
4247                    shutil.move(new_path, old_path)
4248                    assert self.load()
4249                    shutil.rmtree(tmp_path)
4250                    return True
4251                except Exception as e:
4252                    print(f"Error applying the restored files: {e}")
4253                    shutil.move(tmp_path, old_path)
4254                    return False
4255            else:
4256                if debug:
4257                    print(f"Warning: Hash mismatch after uncompressing '{filename}'. Expected from filename: {expected_hash_from_filename}, Got: {extracted_hash}")
4258                # Optionally remove the extracted folder if the hash doesn't match
4259                # shutil.rmtree(output_folder_path, ignore_errors=True)
4260                return False
4261
4262        except Exception as e:
4263            print(f"Error during uncompression or hash check: {e}")
4264            return False

Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash.

The SHA1 hash is extracted from the archive's filename, which must follow the format: 'zakatdb_v__.tar.lzma'. This format is essential for successful restoration.

Parameters:

  • tar_lzma_path (str): The path to the .tar.lzma file.
  • output_folder_path (str, optional): The directory to extract the contents to. Defaults to "uncompressed".
  • debug (bool, optional): Whether to print debug information. Default is False.

Returns:

  • bool: True if the restoration was successful and the hash matches, False otherwise.
def test(self, debug: bool = False) -> bool:
4691    def test(self, debug: bool = False) -> bool:
4692        if debug:
4693            print('test', f'debug={debug}')
4694        try:
4695
4696            self._test_core(True, debug)
4697            self._test_core(False, debug)
4698
4699            # test_names
4700            self.reset()
4701            x = "test_names"
4702            failed = False
4703            try:
4704                assert self.name(x) == ''
4705            except:
4706                failed = True
4707            assert failed
4708            assert self.names() == {}
4709            failed = False
4710            try:
4711                assert self.name(x, 'qwe') == ''
4712            except:
4713                failed = True
4714            assert failed
4715            account_id0 = self.create_account(x)
4716            assert isinstance(account_id0, AccountID)
4717            assert int(account_id0) > 0
4718            assert self.name(account_id0) == x
4719            assert self.name(account_id0, 'qwe') == 'qwe'
4720            if debug:
4721                print(self.names(keyword='qwe'))
4722            assert self.names(keyword='asd') == {}
4723            assert self.names(keyword='qwe') == {'qwe': account_id0}
4724
4725            # test_create_account
4726            account_name = "test_account"
4727            assert self.names(keyword=account_name) == {}
4728            account_id = self.create_account(account_name)
4729            assert isinstance(account_id, AccountID)
4730            assert int(account_id) > 0
4731            assert account_id in self.__vault.account
4732            assert self.name(account_id) == account_name
4733            assert self.names(keyword=account_name) == {account_name: account_id}
4734
4735            failed = False
4736            try:
4737                self.create_account(account_name)
4738            except:
4739                failed = True
4740            assert failed
4741
4742            # bad are names is forbidden
4743
4744            for bad_name in [
4745                None,
4746                '',
4747                Time.time(),
4748                -Time.time(),
4749                f'{Time.time()}',
4750                f'{-Time.time()}',
4751                0.0,
4752                '0.0',
4753                ' ',
4754            ]:
4755                failed = False
4756                try:
4757                    self.create_account(bad_name)
4758                except:
4759                    failed = True
4760                assert failed
4761
4762            # rename account
4763            assert self.name(account_id) == account_name
4764            assert self.name(account_id, 'asd') == 'asd'
4765            assert self.name(account_id) == 'asd'
4766            # use old and not used name
4767            account_id2 = self.create_account(account_name)
4768            assert int(account_id2) > 0
4769            assert account_id != account_id2
4770            assert self.name(account_id2) == account_name
4771            assert self.names(keyword=account_name) == {account_name: account_id2}
4772
4773            assert self.__history()
4774            count = len(self.__vault.history)
4775            if debug:
4776                print('history-count', count)
4777            assert count == 8
4778
4779            assert self.recall(dry=False, debug=debug)
4780            assert self.name(account_id2) == ''
4781            assert self.account_exists(account_id2)
4782            assert self.recall(dry=False, debug=debug)
4783            assert not self.account_exists(account_id2)
4784            assert self.recall(dry=False, debug=debug)
4785            assert self.name(account_id) == account_name
4786            assert self.recall(dry=False, debug=debug)
4787            assert self.account_exists(account_id)
4788            assert self.recall(dry=False, debug=debug)
4789            assert not self.account_exists(account_id)
4790            assert self.names(keyword='qwe') == {'qwe': account_id0}
4791            assert self.recall(dry=False, debug=debug)
4792            assert self.names(keyword='qwe') == {}
4793            assert self.name(account_id0) == x
4794            assert self.recall(dry=False, debug=debug)
4795            assert self.name(account_id0) == ''
4796            assert self.account_exists(account_id0)
4797            assert self.recall(dry=False, debug=debug)
4798            assert not self.account_exists(account_id0)
4799            assert not self.recall(dry=False, debug=debug)
4800
4801            # Not allowed for duplicate transactions in the same account and time
4802
4803            created = Time.time()
4804            same_account_id = self.create_account('same')
4805            self.track(100, 'test-1', same_account_id, True, created)
4806            failed = False
4807            try:
4808                self.track(50, 'test-1', same_account_id, True, created)
4809            except:
4810                failed = True
4811            assert failed is True
4812
4813            self.reset()
4814
4815            # Same account transfer
4816            for x in [1, 'a', True, 1.8, None]:
4817                failed = False
4818                try:
4819                    self.transfer(1, x, x, 'same-account', debug=debug)
4820                except:
4821                    failed = True
4822                assert failed is True
4823
4824            # Always preserve box age during transfer
4825
4826            series: list[tuple[int, int]] = [
4827                (30, 4),
4828                (60, 3),
4829                (90, 2),
4830            ]
4831            case = {
4832                3000: {
4833                    'series': series,
4834                    'rest': 15000,
4835                },
4836                6000: {
4837                    'series': series,
4838                    'rest': 12000,
4839                },
4840                9000: {
4841                    'series': series,
4842                    'rest': 9000,
4843                },
4844                18000: {
4845                    'series': series,
4846                    'rest': 0,
4847                },
4848                27000: {
4849                    'series': series,
4850                    'rest': -9000,
4851                },
4852                36000: {
4853                    'series': series,
4854                    'rest': -18000,
4855                },
4856            }
4857
4858            selected_time = Time.time() - ZakatTracker.TimeCycle()
4859            ages_account_id = self.create_account('ages')
4860            future_account_id = self.create_account('future')
4861
4862            for total in case:
4863                if debug:
4864                    print('--------------------------------------------------------')
4865                    print(f'case[{total}]', case[total])
4866                for x in case[total]['series']:
4867                    self.track(
4868                        unscaled_value=x[0],
4869                        desc=f'test-{x} ages',
4870                        account=ages_account_id,
4871                        created_time_ns=selected_time * x[1],
4872                    )
4873
4874                unscaled_total = self.unscale(total)
4875                if debug:
4876                    print('unscaled_total', unscaled_total)
4877                refs = self.transfer(
4878                    unscaled_amount=unscaled_total,
4879                    from_account=ages_account_id,
4880                    to_account=future_account_id,
4881                    desc='Zakat Movement',
4882                    debug=debug,
4883                )
4884
4885                if debug:
4886                    print('refs', refs)
4887
4888                ages_cache_balance = self.balance(ages_account_id)
4889                ages_fresh_balance = self.balance(ages_account_id, False)
4890                rest = case[total]['rest']
4891                if debug:
4892                    print('source', ages_cache_balance, ages_fresh_balance, rest)
4893                assert ages_cache_balance == rest
4894                assert ages_fresh_balance == rest
4895
4896                future_cache_balance = self.balance(future_account_id)
4897                future_fresh_balance = self.balance(future_account_id, False)
4898                if debug:
4899                    print('target', future_cache_balance, future_fresh_balance, total)
4900                    print('refs', refs)
4901                assert future_cache_balance == total
4902                assert future_fresh_balance == total
4903
4904                # TODO: check boxes times for `ages` should equal box times in `future`
4905                for ref in self.__vault.account[ages_account_id].box:
4906                    ages_capital = self.__vault.account[ages_account_id].box[ref].capital
4907                    ages_rest = self.__vault.account[ages_account_id].box[ref].rest
4908                    future_capital = 0
4909                    if ref in self.__vault.account[future_account_id].box:
4910                        future_capital = self.__vault.account[future_account_id].box[ref].capital
4911                    future_rest = 0
4912                    if ref in self.__vault.account[future_account_id].box:
4913                        future_rest = self.__vault.account[future_account_id].box[ref].rest
4914                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
4915                        if debug:
4916                            print('================================================================')
4917                            print('ages', ages_capital, ages_rest)
4918                            print('future', future_capital, future_rest)
4919                        if ages_rest == 0:
4920                            assert ages_capital == future_capital
4921                        elif ages_rest < 0:
4922                            assert -ages_capital == future_capital
4923                        elif ages_rest > 0:
4924                            assert ages_capital == ages_rest + future_capital
4925                self.reset()
4926                assert len(self.__vault.history) == 0
4927
4928            assert self.__history()
4929            assert self.__history(False) is False
4930            assert self.__history() is False
4931            assert self.__history(True)
4932            assert self.__history()
4933            if debug:
4934                print('####################################################################')
4935
4936            wallet_account_id = self.create_account('wallet')
4937            safe_account_id = self.create_account('safe')
4938            bank_account_id = self.create_account('bank')
4939            transaction = [
4940                (
4941                    20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1,
4942                    2000, 2000, 2000, 1, 1,
4943                ),
4944                (
4945                    750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2,
4946                    75000, 75000, 75000, 1, 1,
4947                ),
4948                (
4949                    600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2,
4950                    60000, 60000, 60000, 1, 1,
4951                ),
4952            ]
4953            for z in transaction:
4954                lock = self.lock()
4955                x = z[1]
4956                y = z[2]
4957                self.transfer(
4958                    unscaled_amount=z[0],
4959                    from_account=x,
4960                    to_account=y,
4961                    desc='test-transfer',
4962                    debug=debug,
4963                )
4964                zz = self.balance(x)
4965                if debug:
4966                    print(zz, z)
4967                assert zz == z[3]
4968                xx = self.accounts()[x]
4969                assert xx.balance == z[3]
4970                assert self.balance(x, False) == z[4]
4971                assert xx.balance == z[4]
4972
4973                s = 0
4974                log = self.__vault.account[x].log
4975                for i in log:
4976                    s += log[i].value
4977                if debug:
4978                    print('s', s, 'z[5]', z[5])
4979                assert s == z[5]
4980
4981                assert self.box_size(x) == z[6]
4982                assert self.log_size(x) == z[7]
4983
4984                yy = self.accounts()[y]
4985                assert self.balance(y) == z[8]
4986                assert yy.balance == z[8]
4987                assert self.balance(y, False) == z[9]
4988                assert yy.balance == z[9]
4989
4990                s = 0
4991                log = self.__vault.account[y].log
4992                for i in log:
4993                    s += log[i].value
4994                assert s == z[10]
4995
4996                assert self.box_size(y) == z[11]
4997                assert self.log_size(y) == z[12]
4998                assert lock is not None
4999                assert self.free(lock)
5000
5001            assert self.nolock()
5002            history_count = len(self.__vault.history)
5003            transaction_count = len(transaction)
5004            if debug:
5005                print('history-count', history_count, transaction_count)
5006            assert history_count == transaction_count * 3
5007            assert not self.free(Time.time())
5008            assert self.free(self.lock())
5009            assert self.nolock()
5010            assert len(self.__vault.history) == transaction_count * 3
5011
5012            # recall
5013
5014            assert self.nolock()
5015            for i in range(transaction_count * 3, 0, -1):
5016                assert len(self.__vault.history) == i
5017                assert self.recall(dry=False, debug=debug) is True
5018            assert len(self.__vault.history) == 0
5019            assert self.recall(dry=False, debug=debug) is False
5020            assert len(self.__vault.history) == 0
5021
5022            # exchange
5023
5024            cash_account_id = self.create_account('cash')
5025            self.exchange(cash_account_id, 25, 3.75, '2024-06-25')
5026            self.exchange(cash_account_id, 22, 3.73, '2024-06-22')
5027            self.exchange(cash_account_id, 15, 3.69, '2024-06-15')
5028            self.exchange(cash_account_id, 10, 3.66)
5029
5030            assert self.nolock()
5031
5032            bank_account_id = self.create_account('bank')
5033            for i in range(1, 30):
5034                exchange = self.exchange(cash_account_id, i)
5035                rate, description, created = exchange.rate, exchange.description, exchange.time
5036                if debug:
5037                    print(i, rate, description, created)
5038                assert created
5039                if i < 10:
5040                    assert rate == 1
5041                    assert description is None
5042                elif i == 10:
5043                    assert rate == 3.66
5044                    assert description is None
5045                elif i < 15:
5046                    assert rate == 3.66
5047                    assert description is None
5048                elif i == 15:
5049                    assert rate == 3.69
5050                    assert description is not None
5051                elif i < 22:
5052                    assert rate == 3.69
5053                    assert description is not None
5054                elif i == 22:
5055                    assert rate == 3.73
5056                    assert description is not None
5057                elif i >= 25:
5058                    assert rate == 3.75
5059                    assert description is not None
5060                exchange = self.exchange(bank_account_id, i)
5061                rate, description, created = exchange.rate, exchange.description, exchange.time
5062                if debug:
5063                    print(i, rate, description, created)
5064                assert created
5065                assert rate == 1
5066                assert description is None
5067
5068            assert len(self.__vault.exchange) == 1
5069            assert len(self.exchanges()) == 1
5070            self.__vault.exchange.clear()
5071            assert len(self.__vault.exchange) == 0
5072            assert len(self.exchanges()) == 0
5073            self.reset()
5074
5075            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
5076            cash_account_id = self.create_account('cash')
5077            self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25')
5078            self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22')
5079            self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15')
5080            self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66)
5081
5082            assert self.nolock()
5083
5084            test_account_id = self.create_account('test')
5085            for i in [x * 0.12 for x in range(-15, 21)]:
5086                if i <= 0:
5087                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange()
5088                else:
5089                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange()
5090
5091            assert self.nolock()
5092
5093           # اختبار النتائج باستخدام التواريخ بالنانو ثانية
5094            bank_account_id = self.create_account('bank')
5095            for i in range(1, 31):
5096                timestamp_ns = ZakatTracker.day_to_time(i)
5097                exchange = self.exchange(cash_account_id, timestamp_ns)
5098                rate, description, created = exchange.rate, exchange.description, exchange.time
5099                if debug:
5100                    print(i, rate, description, created)
5101                assert created
5102                if i < 10:
5103                    assert rate == 1
5104                    assert description is None
5105                elif i == 10:
5106                    assert rate == 3.66
5107                    assert description is None
5108                elif i < 15:
5109                    assert rate == 3.66
5110                    assert description is None
5111                elif i == 15:
5112                    assert rate == 3.69
5113                    assert description is not None
5114                elif i < 22:
5115                    assert rate == 3.69
5116                    assert description is not None
5117                elif i == 22:
5118                    assert rate == 3.73
5119                    assert description is not None
5120                elif i >= 25:
5121                    assert rate == 3.75
5122                    assert description is not None
5123                exchange = self.exchange(bank_account_id, i)
5124                rate, description, created = exchange.rate, exchange.description, exchange.time
5125                if debug:
5126                    print(i, rate, description, created)
5127                assert created
5128                assert rate == 1
5129                assert description is None
5130
5131            assert self.nolock()
5132            if debug:
5133                print(self.__vault.history, len(self.__vault.history))
5134            for _ in range(len(self.__vault.history)):
5135                assert self.recall(dry=False, debug=debug)
5136            assert not self.recall(dry=False, debug=debug)
5137
5138            self.reset()
5139
5140            # test transfer between accounts with different exchange rate
5141
5142            a_SAR = self.create_account('Bank (SAR)')
5143            b_USD = self.create_account('Bank (USD)')
5144            c_SAR = self.create_account('Safe (SAR)')
5145            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
5146            for case in [
5147                (0, a_SAR, 'SAR Gift', 1000, 100000),
5148                (1, a_SAR, 1),
5149                (0, b_USD, 'USD Gift', 500, 50000),
5150                (1, b_USD, 1),
5151                (2, b_USD, 3.75),
5152                (1, b_USD, 3.75),
5153                (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500),
5154                (0, c_SAR, 'Salary', 750, 75000),
5155                (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000),
5156                (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100),
5157            ]:
5158                if debug:
5159                    print('case', case)
5160                match (case[0]):
5161                    case 0:  # track
5162                        _, account, desc, x, balance = case
5163                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
5164
5165                        cached_value = self.balance(account, cached=True)
5166                        fresh_value = self.balance(account, cached=False)
5167                        if debug:
5168                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
5169                        assert cached_value == balance
5170                        assert fresh_value == balance
5171                    case 1:  # check-exchange
5172                        _, account, expected_rate = case
5173                        t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
5174                        if debug:
5175                            print('t-exchange', t_exchange)
5176                        assert t_exchange.rate == expected_rate
5177                    case 2:  # do-exchange
5178                        _, account, rate = case
5179                        self.exchange(account, rate=rate, debug=debug)
5180                        b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
5181                        if debug:
5182                            print('b-exchange', b_exchange)
5183                        assert b_exchange.rate == rate
5184                    case 3:  # transfer
5185                        _, x, a, b, desc, a_balance, b_balance = case
5186                        self.transfer(x, a, b, desc, debug=debug)
5187
5188                        cached_value = self.balance(a, cached=True)
5189                        fresh_value = self.balance(a, cached=False)
5190                        if debug:
5191                            print(
5192                                'account', a,
5193                                'cached_value', cached_value,
5194                                'fresh_value', fresh_value,
5195                                'a_balance', a_balance,
5196                            )
5197                        assert cached_value == a_balance
5198                        assert fresh_value == a_balance
5199
5200                        cached_value = self.balance(b, cached=True)
5201                        fresh_value = self.balance(b, cached=False)
5202                        if debug:
5203                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
5204                        assert cached_value == b_balance
5205                        assert fresh_value == b_balance
5206
5207            # Transfer all in many chunks randomly from B to A
5208            a_SAR_balance = 137125
5209            b_USD_balance = 50100
5210            b_USD_exchange = self.exchange(b_USD)
5211            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
5212            if debug:
5213                print('amounts', amounts)
5214            i = 0
5215            for x in amounts:
5216                if debug:
5217                    print(f'{i} - transfer-with-exchange({x})')
5218                self.transfer(
5219                    unscaled_amount=self.unscale(x),
5220                    from_account=b_USD,
5221                    to_account=a_SAR,
5222                    desc=f'{x} USD -> SAR',
5223                    debug=debug,
5224                )
5225
5226                b_USD_balance -= x
5227                cached_value = self.balance(b_USD, cached=True)
5228                fresh_value = self.balance(b_USD, cached=False)
5229                if debug:
5230                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5231                          b_USD_balance)
5232                assert cached_value == b_USD_balance
5233                assert fresh_value == b_USD_balance
5234
5235                a_SAR_balance += int(x * b_USD_exchange.rate)
5236                cached_value = self.balance(a_SAR, cached=True)
5237                fresh_value = self.balance(a_SAR, cached=False)
5238                if debug:
5239                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5240                          a_SAR_balance, 'rate', b_USD_exchange.rate)
5241                assert cached_value == a_SAR_balance
5242                assert fresh_value == a_SAR_balance
5243                i += 1
5244
5245            # Transfer all in many chunks randomly from C to A
5246            c_SAR_balance = 37500
5247            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
5248            if debug:
5249                print('amounts', amounts)
5250            i = 0
5251            for x in amounts:
5252                if debug:
5253                    print(f'{i} - transfer-with-exchange({x})')
5254                self.transfer(
5255                    unscaled_amount=self.unscale(x),
5256                    from_account=c_SAR,
5257                    to_account=a_SAR,
5258                    desc=f'{x} SAR -> a_SAR',
5259                    debug=debug,
5260                )
5261
5262                c_SAR_balance -= x
5263                cached_value = self.balance(c_SAR, cached=True)
5264                fresh_value = self.balance(c_SAR, cached=False)
5265                if debug:
5266                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5267                          c_SAR_balance)
5268                assert cached_value == c_SAR_balance
5269                assert fresh_value == c_SAR_balance
5270
5271                a_SAR_balance += x
5272                cached_value = self.balance(a_SAR, cached=True)
5273                fresh_value = self.balance(a_SAR, cached=False)
5274                if debug:
5275                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5276                          a_SAR_balance)
5277                assert cached_value == a_SAR_balance
5278                assert fresh_value == a_SAR_balance
5279                i += 1
5280
5281            assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}')
5282
5283            # check & zakat with exchange rates for many cycles
5284
5285            lock = None
5286            safe_account_id = self.create_account('safe')
5287            cave_account_id = self.create_account('cave')
5288            for rate, values in {
5289                1: {
5290                    'in': [1000, 2000, 10000],
5291                    'exchanged': [100000, 200000, 1000000],
5292                    'out': [2500, 5000, 73140],
5293                },
5294                3.75: {
5295                    'in': [200, 1000, 5000],
5296                    'exchanged': [75000, 375000, 1875000],
5297                    'out': [1875, 9375, 137138],
5298                },
5299            }.items():
5300                a, b, c = values['in']
5301                m, n, o = values['exchanged']
5302                x, y, z = values['out']
5303                if debug:
5304                    print('rate', rate, 'values', values)
5305                for case in [
5306                    (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5307                        {safe_account_id: {0: {'below_nisab': x}}},
5308                    ], False, m),
5309                    (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5310                        {safe_account_id: {0: {'count': 1, 'total': y}}},
5311                    ], True, n),
5312                    (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [
5313                        {cave_account_id: {0: {'count': 3, 'total': z}}},
5314                    ], True, o),
5315                ]:
5316                    if debug:
5317                        print(f'############# check(rate: {rate}) #############')
5318                        print('case', case)
5319                    self.reset()
5320                    self.exchange(account=case[1], created_time_ns=case[2], rate=rate)
5321                    self.track(
5322                        unscaled_value=case[0],
5323                        desc='test-check',
5324                        account=case[1],
5325                        created_time_ns=case[2],
5326                    )
5327                    assert self.snapshot()
5328
5329                    # assert self.nolock()
5330                    # history_size = len(self.__vault.history)
5331                    # print('history_size', history_size)
5332                    # assert history_size == 2
5333                    lock = self.lock()
5334                    assert lock
5335                    assert not self.nolock()
5336                    assert self.__vault.cache.zakat is None
5337                    report = self.check(2.17, None, debug)
5338                    if debug:
5339                        print('[report]', report)
5340                    assert case[4] == report.valid
5341                    assert case[5] == report.summary.total_wealth
5342                    assert case[5] == report.summary.total_zakatable_amount
5343                    if report.valid:
5344                        assert self.__vault.cache.zakat is not None
5345                        assert report.plan
5346                        assert self.zakat(report, debug=debug)
5347                        assert self.__vault.cache.zakat is None
5348                        if debug:
5349                            pp().pprint(self.__vault)
5350                        self._test_storage(debug=debug)
5351
5352                        for x in report.plan:
5353                            assert case[1] == x
5354                            if report.plan[x][0].below_nisab:
5355                                if debug:
5356                                    print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab'])
5357                                assert report.plan[x][0].total == case[3][0][x][0]['below_nisab']
5358                            else:
5359                                if debug:
5360                                    print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total'])
5361                                    print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total'])
5362                                    print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count'])
5363                                assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total']
5364                                assert int(report.plan[x][0].total) == case[3][0][x][0]['total']
5365                                assert report.plan[x][0].count == case[3][0][x][0]['count']
5366                    else:
5367                        assert self.__vault.cache.zakat is None
5368                        result = self.zakat(report, debug=debug)
5369                        if debug:
5370                            print('zakat-result', result, case[4])
5371                        assert result == case[4]
5372                        report = self.check(2.17, None, debug)
5373                        assert report.valid is False
5374            self._test_storage(account_id=cave_account_id, debug=debug)
5375
5376            # recall after zakat
5377
5378            history_size = len(self.__vault.history)
5379            if debug:
5380                print('history_size', history_size)
5381            assert history_size == 3
5382            assert not self.nolock()
5383            assert self.recall(dry=False, debug=debug) is False
5384            self.free(lock)
5385            assert self.nolock()
5386
5387            for i in range(3, 0, -1):
5388                history_size = len(self.__vault.history)
5389                if debug:
5390                    print('history_size', history_size)
5391                assert history_size == i
5392                assert self.recall(dry=False, debug=debug) is True
5393
5394            assert self.nolock()
5395            assert self.recall(dry=False, debug=debug) is False
5396
5397            history_size = len(self.__vault.history)
5398            if debug:
5399                print('history_size', history_size)
5400            assert history_size == 0
5401
5402            account_size = len(self.__vault.account)
5403            if debug:
5404                print('account_size', account_size)
5405            assert account_size == 0
5406
5407            report_size = len(self.__vault.report)
5408            if debug:
5409                print('report_size', report_size)
5410            assert report_size == 0
5411
5412            assert self.nolock()
5413
5414            # csv
5415
5416            csv_count = 1000
5417
5418            for with_rate, path in {
5419                False: 'test-import_csv-no-exchange',
5420                True: 'test-import_csv-with-exchange',
5421            }.items():
5422
5423                if debug:
5424                    print('test_import_csv', with_rate, path)
5425
5426                csv_path = path + '.csv'
5427                if os.path.exists(csv_path):
5428                    os.remove(csv_path)
5429                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
5430                if debug:
5431                    print('generate_random_csv_file', c)
5432                assert c == csv_count
5433                assert os.path.getsize(csv_path) > 0
5434                cache_path = self.import_csv_cache_path()
5435                if os.path.exists(cache_path):
5436                    os.remove(cache_path)
5437                self.reset()
5438                lock = self.lock()
5439                import_report = self.import_csv(csv_path, debug=debug)
5440                bad_count = len(import_report.bad)
5441                if debug:
5442                    print(f'csv-imported: {import_report.statistics} = count({csv_count})')
5443                    print('bad', import_report.bad)
5444                assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count
5445                assert import_report.statistics.created == csv_count
5446                assert bad_count == 0
5447                assert bad_count == import_report.statistics.bad
5448                tmp_size = os.path.getsize(cache_path)
5449                assert tmp_size > 0
5450
5451                import_report_2 = self.import_csv(csv_path, debug=debug)
5452                bad_2_count = len(import_report_2.bad)
5453                if debug:
5454                    print(f'csv-imported: {import_report_2}')
5455                    print('bad', import_report_2.bad)
5456                assert tmp_size == os.path.getsize(cache_path)
5457                assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count
5458                assert import_report.statistics.created == import_report_2.statistics.found
5459                assert bad_count == bad_2_count
5460                assert import_report_2.statistics.found == csv_count
5461                assert bad_2_count == 0
5462                assert bad_2_count == import_report_2.statistics.bad
5463                assert import_report_2.statistics.created == 0
5464
5465                # payment parts
5466
5467                positive_parts = self.build_payment_parts(100, positive_only=True)
5468                assert self.check_payment_parts(positive_parts) != 0
5469                assert self.check_payment_parts(positive_parts) != 0
5470                all_parts = self.build_payment_parts(300, positive_only=False)
5471                assert self.check_payment_parts(all_parts) != 0
5472                assert self.check_payment_parts(all_parts) != 0
5473                if debug:
5474                    pp().pprint(positive_parts)
5475                    pp().pprint(all_parts)
5476                # dynamic discount
5477                suite = []
5478                count = 3
5479                for exceed in [False, True]:
5480                    case = []
5481                    for part in [positive_parts, all_parts]:
5482                        #part = parts.copy()
5483                        demand = part.demand
5484                        if debug:
5485                            print(demand, part.total)
5486                        i = 0
5487                        z = demand / count
5488                        cp = PaymentParts(
5489                            demand=demand,
5490                            exceed=exceed,
5491                            total=part.total,
5492                        )
5493                        j = ''
5494                        for x, y in part.account.items():
5495                            x_exchange = self.exchange(x)
5496                            zz = self.exchange_calc(z, 1, x_exchange.rate)
5497                            if exceed and zz <= demand:
5498                                i += 1
5499                                y.part = zz
5500                                if debug:
5501                                    print(exceed, y)
5502                                cp.account[x] = y
5503                                case.append(y)
5504                            elif not exceed and y.balance >= zz:
5505                                i += 1
5506                                y.part = zz
5507                                if debug:
5508                                    print(exceed, y)
5509                                cp.account[x] = y
5510                                case.append(y)
5511                            j = x
5512                            if i >= count:
5513                                break
5514                        if debug:
5515                            print('[debug]', j)
5516                            print('[debug]', cp.account[j])
5517                        if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0):
5518                            suite.append(cp)
5519                if debug:
5520                    print('suite', len(suite))
5521                for case in suite:
5522                    if debug:
5523                        print('case', case)
5524                    result = self.check_payment_parts(case)
5525                    if debug:
5526                        print('check_payment_parts', result, f'exceed: {exceed}')
5527                    assert result == 0
5528
5529                    assert self.__vault.cache.zakat is None
5530                    report = self.check(2.17, None, debug)
5531                    if debug:
5532                        print('valid', report.valid)
5533                    zakat_result = self.zakat(report, parts=case, debug=debug)
5534                    if debug:
5535                        print('zakat-result', zakat_result)
5536                    assert report.valid == zakat_result
5537                    # test verified zakat report is required
5538                    if zakat_result:
5539                        assert self.__vault.cache.zakat is None
5540                        failed = False
5541                        try:
5542                            self.zakat(report, parts=case, debug=debug)
5543                        except:
5544                            failed = True
5545                        assert failed
5546
5547                assert self.free(lock)
5548
5549            assert self.save(path + f'.{self.ext()}')
5550            assert self.save(f'1000-transactions-test.{self.ext()}')
5551            return True
5552        except Exception as e:
5553            if self.__debug_output:
5554                pp().pprint(self.__vault)
5555                print('============================================================================')
5556                pp().pprint(self.__debug_output)
5557            assert self.save(f'test-snapshot.{self.ext()}')
5558            raise e
class AccountID(builtins.str):
250class AccountID(str):
251    """
252    A class representing an Account ID, which is a string that must be a positive integer greater than zero.
253    Inherits from str, so it behaves like a string.
254    """
255
256    def __new__(cls, value):
257        """
258        Creates a new AccountID instance.
259
260        Parameters:
261        - value (str): The string value to be used as the AccountID.
262
263        Raises:
264        - ValueError: If the provided value is not a valid AccountID.
265
266        Returns:
267        - AccountID: A new AccountID instance.
268        """
269        if isinstance(value, Timestamp):
270            value = str(value) # convert timestamp to string
271        if not cls.is_valid_account_id(value):
272            raise ValueError(f"Invalid AccountID: '{value}'")
273        return super().__new__(cls, value)
274
275    @staticmethod
276    def is_valid_account_id(s: str) -> bool:
277        """
278        Checks if a string is a valid AccountID (positive integer greater than zero).
279
280        Parameters:
281        - s (str): The string to check.
282
283        Returns:
284         - bool: True if the string is a valid AccountID, False otherwise.
285        """
286        if not s:
287            return False
288
289        try:
290            if s[0] == '0':
291                return False
292            if s.startswith('-'):
293                return False
294            if not s.isdigit():
295                return False
296        except:
297            pass
298
299        try:
300            num = int(s)
301            return num > 0
302        except ValueError:
303            return False
304
305    @classmethod
306    def test(cls, debug: bool = False):
307        """
308        Runs tests for the AccountID class to ensure it behaves correctly.
309
310        This method tests various valid and invalid input strings to verify that:
311            - Valid AccountIDs are created successfully.
312            - Invalid AccountIDs raise ValueError exceptions.
313        """
314        test_data = {
315            "123": True,
316            "0": False,
317            "01": False,
318            "-1": False,
319            "abc": False,
320            "12.3": False,
321            "": False,
322            "9999999999999999999999999999999999999": True,
323            "1": True,
324            "10": True,
325            "000000000000000001": False,
326            " ": False,
327            "1 ": False,
328            " 1": False,
329            "1.0": False,
330            Timestamp(12345): True, # Test timestamp input
331        }
332
333        for input_value, expected_output in test_data.items():
334            if expected_output:
335                try:
336                    account_id = cls(input_value)
337                    if debug:
338                        print(f'"{str(account_id)}", "{input_value}"')
339                    if isinstance(input_value, Timestamp):
340                        input_value = str(input_value)
341                    assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'"
342                except ValueError as e:
343                    assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}"
344            else:
345                try:
346                    cls(input_value)
347                    assert False, f"Expected ValueError for invalid input: '{input_value}'"
348                except ValueError as e:
349                    pass  # Expected exception

A class representing an Account ID, which is a string that must be a positive integer greater than zero. Inherits from str, so it behaves like a string.

AccountID(value)
256    def __new__(cls, value):
257        """
258        Creates a new AccountID instance.
259
260        Parameters:
261        - value (str): The string value to be used as the AccountID.
262
263        Raises:
264        - ValueError: If the provided value is not a valid AccountID.
265
266        Returns:
267        - AccountID: A new AccountID instance.
268        """
269        if isinstance(value, Timestamp):
270            value = str(value) # convert timestamp to string
271        if not cls.is_valid_account_id(value):
272            raise ValueError(f"Invalid AccountID: '{value}'")
273        return super().__new__(cls, value)

Creates a new AccountID instance.

Parameters:

  • value (str): The string value to be used as the AccountID.

Raises:

  • ValueError: If the provided value is not a valid AccountID.

Returns:

  • AccountID: A new AccountID instance.
@staticmethod
def is_valid_account_id(s: str) -> bool:
275    @staticmethod
276    def is_valid_account_id(s: str) -> bool:
277        """
278        Checks if a string is a valid AccountID (positive integer greater than zero).
279
280        Parameters:
281        - s (str): The string to check.
282
283        Returns:
284         - bool: True if the string is a valid AccountID, False otherwise.
285        """
286        if not s:
287            return False
288
289        try:
290            if s[0] == '0':
291                return False
292            if s.startswith('-'):
293                return False
294            if not s.isdigit():
295                return False
296        except:
297            pass
298
299        try:
300            num = int(s)
301            return num > 0
302        except ValueError:
303            return False

Checks if a string is a valid AccountID (positive integer greater than zero).

Parameters:

  • s (str): The string to check.

Returns:

  • bool: True if the string is a valid AccountID, False otherwise.
@classmethod
def test(cls, debug: bool = False):
305    @classmethod
306    def test(cls, debug: bool = False):
307        """
308        Runs tests for the AccountID class to ensure it behaves correctly.
309
310        This method tests various valid and invalid input strings to verify that:
311            - Valid AccountIDs are created successfully.
312            - Invalid AccountIDs raise ValueError exceptions.
313        """
314        test_data = {
315            "123": True,
316            "0": False,
317            "01": False,
318            "-1": False,
319            "abc": False,
320            "12.3": False,
321            "": False,
322            "9999999999999999999999999999999999999": True,
323            "1": True,
324            "10": True,
325            "000000000000000001": False,
326            " ": False,
327            "1 ": False,
328            " 1": False,
329            "1.0": False,
330            Timestamp(12345): True, # Test timestamp input
331        }
332
333        for input_value, expected_output in test_data.items():
334            if expected_output:
335                try:
336                    account_id = cls(input_value)
337                    if debug:
338                        print(f'"{str(account_id)}", "{input_value}"')
339                    if isinstance(input_value, Timestamp):
340                        input_value = str(input_value)
341                    assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'"
342                except ValueError as e:
343                    assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}"
344            else:
345                try:
346                    cls(input_value)
347                    assert False, f"Expected ValueError for invalid input: '{input_value}'"
348                except ValueError as e:
349                    pass  # Expected exception

Runs tests for the AccountID class to ensure it behaves correctly.

This method tests various valid and invalid input strings to verify that: - Valid AccountIDs are created successfully. - Invalid AccountIDs raise ValueError exceptions.

@dataclasses.dataclass
class AccountDetails:
352@dataclasses.dataclass
353class AccountDetails:
354    """
355    Details of an account.
356
357    Attributes:
358    - account_id: The unique identifier (ID) of the account.
359    - account_name: Human-readable name of the account.
360    - balance: The current cached balance of the account.
361    """
362    account_id: AccountID
363    account_name: str
364    balance: int

Details of an account.

Attributes:

  • account_id: The unique identifier (ID) of the account.
  • account_name: Human-readable name of the account.
  • balance: The current cached balance of the account.
AccountDetails( account_id: AccountID, account_name: str, balance: int)
account_id: AccountID
account_name: str
balance: int
class Timestamp(builtins.int):
188class Timestamp(int):
189    """Represents a timestamp as an integer, which must be greater than zero."""
190
191    def __new__(cls, value):
192        """
193        Creates a new Timestamp instance.
194
195        Parameters:
196        - value (int or str): The integer value to be used as the timestamp.
197
198        Raises:
199        - TypeError: If the provided value is not an integer or a string representing an integer.
200        - ValueError: If the provided value is not greater than zero.
201
202        Returns:
203        - Timestamp: A new Timestamp instance.
204        """
205        if isinstance(value, str):
206            try:
207                value = int(value)
208            except ValueError:
209                raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.")
210        if not isinstance(value, int):
211            raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.")
212
213        if value <= 0:
214            raise ValueError("Timestamp value must be greater than zero.")
215
216        return super().__new__(cls, value)
217
218    @classmethod
219    def test(cls):
220        """
221        Runs tests for the Timestamp class to ensure it behaves correctly.
222        """
223        test_data = {
224            123: True,
225            "123": True,
226            0: False,
227            "0": False,
228            -1: False,
229            "-1": False,
230            "abc": False,
231            1: True,
232            "1": True,
233        }
234
235        for input_value, expected_output in test_data.items():
236            if expected_output:
237                try:
238                    timestamp = cls(input_value)
239                    assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'"
240                except (TypeError, ValueError) as e:
241                    assert False, f"Unexpected error for valid input: '{input_value}': {e}"
242            else:
243                try:
244                    cls(input_value)
245                    assert False, f"Expected error for invalid input: '{input_value}'"
246                except (TypeError, ValueError):
247                    pass  # Expected exception

Represents a timestamp as an integer, which must be greater than zero.

Timestamp(value)
191    def __new__(cls, value):
192        """
193        Creates a new Timestamp instance.
194
195        Parameters:
196        - value (int or str): The integer value to be used as the timestamp.
197
198        Raises:
199        - TypeError: If the provided value is not an integer or a string representing an integer.
200        - ValueError: If the provided value is not greater than zero.
201
202        Returns:
203        - Timestamp: A new Timestamp instance.
204        """
205        if isinstance(value, str):
206            try:
207                value = int(value)
208            except ValueError:
209                raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.")
210        if not isinstance(value, int):
211            raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.")
212
213        if value <= 0:
214            raise ValueError("Timestamp value must be greater than zero.")
215
216        return super().__new__(cls, value)

Creates a new Timestamp instance.

Parameters:

  • value (int or str): The integer value to be used as the timestamp.

Raises:

  • TypeError: If the provided value is not an integer or a string representing an integer.
  • ValueError: If the provided value is not greater than zero.

Returns:

  • Timestamp: A new Timestamp instance.
@classmethod
def test(cls):
218    @classmethod
219    def test(cls):
220        """
221        Runs tests for the Timestamp class to ensure it behaves correctly.
222        """
223        test_data = {
224            123: True,
225            "123": True,
226            0: False,
227            "0": False,
228            -1: False,
229            "-1": False,
230            "abc": False,
231            1: True,
232            "1": True,
233        }
234
235        for input_value, expected_output in test_data.items():
236            if expected_output:
237                try:
238                    timestamp = cls(input_value)
239                    assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'"
240                except (TypeError, ValueError) as e:
241                    assert False, f"Unexpected error for valid input: '{input_value}': {e}"
242            else:
243                try:
244                    cls(input_value)
245                    assert False, f"Expected error for invalid input: '{input_value}'"
246                except (TypeError, ValueError):
247                    pass  # Expected exception

Runs tests for the Timestamp class to ensure it behaves correctly.

@dataclasses.dataclass
class Box(zakat.StrictDataclass):
460@dataclasses.dataclass
461class Box(
462        StrictDataclass,
463        # ImmutableWithSelectiveFreeze,
464    ):
465    """
466    Represents a financial box with capital, remaining value, and zakat details.
467
468    Attributes:
469    - capital (int): The initial capital value of the box.
470    - rest (int): The current remaining value within the box.
471    - zakat (BoxZakat): A `BoxZakat` object containing the accumulated zakat information for the box.
472    """
473    capital: int #= dataclasses.field(metadata={"frozen": True})
474    rest: int
475    zakat: BoxZakat

Represents a financial box with capital, remaining value, and zakat details.

Attributes:

  • capital (int): The initial capital value of the box.
  • rest (int): The current remaining value within the box.
  • zakat (BoxZakat): A BoxZakat object containing the accumulated zakat information for the box.
Box(capital: int, rest: int, zakat: zakat.zakat_tracker.BoxZakat)
capital: int
rest: int
@dataclasses.dataclass
class Log(zakat.StrictDataclass):
478@dataclasses.dataclass
479class Log(StrictDataclass):
480    """
481    Represents a log entry for an account.
482
483    Attributes:
484    - value: The value of the log entry.
485    - desc: A description of the log entry.
486    - ref: An optional timestamp reference.
487    - file: A dictionary mapping timestamps to file paths.
488    """
489    value: int
490    desc: str
491    ref: Optional[Timestamp]
492    file: dict[Timestamp, str] = dataclasses.field(default_factory=dict)

Represents a log entry for an account.

Attributes:

  • value: The value of the log entry.
  • desc: A description of the log entry.
  • ref: An optional timestamp reference.
  • file: A dictionary mapping timestamps to file paths.
Log( value: int, desc: str, ref: Optional[Timestamp], file: dict[Timestamp, str] = <factory>)
value: int
desc: str
ref: Optional[Timestamp]
file: dict[Timestamp, str]
@dataclasses.dataclass
class Account(zakat.StrictDataclass):
495@dataclasses.dataclass
496class Account(StrictDataclass):
497    """
498    Represents a financial account.
499
500    Attributes:
501    - balance: The current balance of the account.
502    - created: The timestamp when the account was created.
503    - name: The name of the account.
504    - box: A dictionary mapping timestamps to Box objects.
505    - count: A counter for logs, initialized to 0.
506    - log: A dictionary mapping timestamps to Log objects.
507    - hide: A boolean indicating whether the account is hidden.
508    - zakatable: A boolean indicating whether the account is subject to zakat.
509    """
510    balance: int
511    created: Timestamp
512    name: str = ''
513    box: dict[Timestamp, Box] = dataclasses.field(default_factory=dict)
514    count: int = dataclasses.field(default_factory=factory_value(0))
515    log: dict[Timestamp, Log] = dataclasses.field(default_factory=dict)
516    hide: bool = dataclasses.field(default_factory=factory_value(False))
517    zakatable: bool = dataclasses.field(default_factory=factory_value(True))

Represents a financial account.

Attributes:

  • balance: The current balance of the account.
  • created: The timestamp when the account was created.
  • name: The name of the account.
  • box: A dictionary mapping timestamps to Box objects.
  • count: A counter for logs, initialized to 0.
  • log: A dictionary mapping timestamps to Log objects.
  • hide: A boolean indicating whether the account is hidden.
  • zakatable: A boolean indicating whether the account is subject to zakat.
Account( balance: int, created: Timestamp, name: str = '', box: dict[Timestamp, Box] = <factory>, count: int = <factory>, log: dict[Timestamp, Log] = <factory>, hide: bool = <factory>, zakatable: bool = <factory>)
balance: int
created: Timestamp
name: str = ''
box: dict[Timestamp, Box]
count: int
log: dict[Timestamp, Log]
hide: bool
zakatable: bool
@dataclasses.dataclass
class Exchange(zakat.StrictDataclass):
520@dataclasses.dataclass
521class Exchange(StrictDataclass):
522    """
523    Represents an exchange rate and related information.
524
525    Attributes:
526    - rate: The exchange rate (optional).
527    - description: A description of the exchange (optional).
528    - time: The timestamp of the exchange (optional).
529    """
530    rate: Optional[float] = None
531    description: Optional[str] = None
532    time: Optional[Timestamp] = None

Represents an exchange rate and related information.

Attributes:

  • rate: The exchange rate (optional).
  • description: A description of the exchange (optional).
  • time: The timestamp of the exchange (optional).
Exchange( rate: Optional[float] = None, description: Optional[str] = None, time: Optional[Timestamp] = None)
rate: Optional[float] = None
description: Optional[str] = None
time: Optional[Timestamp] = None
@dataclasses.dataclass
class History(zakat.StrictDataclass):
535@dataclasses.dataclass
536class History(StrictDataclass):
537    """
538    Represents a history entry for an account action.
539
540    Attributes:
541    - action: The action performed.
542    - account: The ID of the account (optional).
543    - ref: An optional timestamp reference.
544    - file: An optional timestamp for a file.
545    - key: An optional key.
546    - value: An optional value.
547    - math: An optional math operation.
548    """
549    action: Action
550    account: Optional[AccountID]
551    ref: Optional[Timestamp]
552    file: Optional[Timestamp]
553    key: Optional[str]
554    value: Optional[any] # !!!
555    math: Optional[MathOperation]

Represents a history entry for an account action.

Attributes:

  • action: The action performed.
  • account: The ID of the account (optional).
  • ref: An optional timestamp reference.
  • file: An optional timestamp for a file.
  • key: An optional key.
  • value: An optional value.
  • math: An optional math operation.
History( action: Action, account: Optional[AccountID], ref: Optional[Timestamp], file: Optional[Timestamp], key: Optional[str], value: Optional[<built-in function any>], math: Optional[MathOperation])
action: Action
account: Optional[AccountID]
ref: Optional[Timestamp]
file: Optional[Timestamp]
key: Optional[str]
value: Optional[<built-in function any>]
math: Optional[MathOperation]
@dataclasses.dataclass
class Vault(zakat.StrictDataclass):
630@dataclasses.dataclass
631class Vault(StrictDataclass):
632    """
633    Represents a vault containing accounts, exchanges, and history.
634
635    Attributes:
636    - account: A dictionary mapping account IDs to Account objects.
637    - exchange: A dictionary mapping account IDs to dictionaries of timestamps and Exchange objects.
638    - history: A dictionary mapping timestamps to dictionaries of History objects.
639    - lock: An optional timestamp for a lock.
640    - report: A dictionary mapping timestamps to tuples.
641    - cache: A Cache object containing cached Zakat-related data.
642    """
643    account: dict[AccountID, Account] = dataclasses.field(default_factory=dict)
644    exchange: dict[AccountID, dict[Timestamp, Exchange]] = dataclasses.field(default_factory=dict)
645    history: dict[Timestamp, dict[Timestamp, History]] = dataclasses.field(default_factory=dict)
646    lock: Optional[Timestamp] = None
647    report: dict[Timestamp, ZakatReport] = dataclasses.field(default_factory=dict)
648    cache: Cache = dataclasses.field(default_factory=Cache)

Represents a vault containing accounts, exchanges, and history.

Attributes:

  • account: A dictionary mapping account IDs to Account objects.
  • exchange: A dictionary mapping account IDs to dictionaries of timestamps and Exchange objects.
  • history: A dictionary mapping timestamps to dictionaries of History objects.
  • lock: An optional timestamp for a lock.
  • report: A dictionary mapping timestamps to tuples.
  • cache: A Cache object containing cached Zakat-related data.
Vault( account: dict[AccountID, Account] = <factory>, exchange: dict[AccountID, dict[Timestamp, Exchange]] = <factory>, history: dict[Timestamp, dict[Timestamp, History]] = <factory>, lock: Optional[Timestamp] = None, report: dict[Timestamp, ZakatReport] = <factory>, cache: zakat.zakat_tracker.Cache = <factory>)
account: dict[AccountID, Account]
exchange: dict[AccountID, dict[Timestamp, Exchange]]
history: dict[Timestamp, dict[Timestamp, History]]
lock: Optional[Timestamp] = None
report: dict[Timestamp, ZakatReport]
@dataclasses.dataclass
class AccountPaymentPart(zakat.StrictDataclass):
651@dataclasses.dataclass
652class AccountPaymentPart(StrictDataclass):
653    """
654    Represents a payment part for an account.
655
656    Attributes:
657    - balance: The balance of the payment part.
658    - rate: The rate of the payment part.
659    - part: The part of the payment.
660    """
661    balance: float
662    rate: float
663    part: float

Represents a payment part for an account.

Attributes:

  • balance: The balance of the payment part.
  • rate: The rate of the payment part.
  • part: The part of the payment.
AccountPaymentPart(balance: float, rate: float, part: float)
balance: float
rate: float
part: float
@dataclasses.dataclass
class PaymentParts(zakat.StrictDataclass):
666@dataclasses.dataclass
667class PaymentParts(StrictDataclass):
668    """
669    Represents payment parts for multiple accounts.
670
671    Attributes:
672    - exceed: A boolean indicating whether the payment exceeds a limit.
673    - demand: The demand for payment.
674    - total: The total payment.
675    - account: A dictionary mapping account references to AccountPaymentPart objects.
676    """
677    exceed: bool
678    demand: int
679    total: float
680    account: dict[AccountID, AccountPaymentPart] = dataclasses.field(default_factory=dict)

Represents payment parts for multiple accounts.

Attributes:

  • exceed: A boolean indicating whether the payment exceeds a limit.
  • demand: The demand for payment.
  • total: The total payment.
  • account: A dictionary mapping account references to AccountPaymentPart objects.
PaymentParts( exceed: bool, demand: int, total: float, account: dict[AccountID, AccountPaymentPart] = <factory>)
exceed: bool
demand: int
total: float
account: dict[AccountID, AccountPaymentPart]
@dataclasses.dataclass
class SubtractAge(zakat.StrictDataclass):
683@dataclasses.dataclass
684class SubtractAge(StrictDataclass):
685    """
686    Represents an age subtraction.
687
688    Attributes:
689    - box_ref: The timestamp reference for the box.
690    - total: The total amount to subtract.
691    """
692    box_ref: Timestamp
693    total: int

Represents an age subtraction.

Attributes:

  • box_ref: The timestamp reference for the box.
  • total: The total amount to subtract.
SubtractAge(box_ref: Timestamp, total: int)
box_ref: Timestamp
total: int
@dataclasses.dataclass
class SubtractAges(zakat.StrictDataclass, list[zakat.zakat_tracker.SubtractAge]):
696@dataclasses.dataclass
697class SubtractAges(StrictDataclass, list[SubtractAge]):
698    """A list of SubtractAge objects."""
699    pass

A list of SubtractAge objects.

@dataclasses.dataclass
class SubtractReport(zakat.StrictDataclass):
702@dataclasses.dataclass
703class SubtractReport(StrictDataclass):
704    """
705    Represents a report of age subtractions.
706
707    Attributes:
708    - log_ref: The timestamp reference for the log.
709    - ages: A list of SubtractAge objects.
710    """
711    log_ref: Timestamp
712    ages: SubtractAges

Represents a report of age subtractions.

Attributes:

  • log_ref: The timestamp reference for the log.
  • ages: A list of SubtractAge objects.
SubtractReport( log_ref: Timestamp, ages: SubtractAges)
log_ref: Timestamp
ages: SubtractAges
@dataclasses.dataclass
class TransferTime(zakat.StrictDataclass):
715@dataclasses.dataclass
716class TransferTime(StrictDataclass):
717    """
718    Represents a transfer time.
719
720    Attributes:
721    - box_ref: The timestamp reference for the box.
722    - log_ref: The timestamp reference for the log.
723    """
724    box_ref: Timestamp
725    log_ref: Timestamp

Represents a transfer time.

Attributes:

  • box_ref: The timestamp reference for the box.
  • log_ref: The timestamp reference for the log.
TransferTime( box_ref: Timestamp, log_ref: Timestamp)
box_ref: Timestamp
log_ref: Timestamp
@dataclasses.dataclass
class TransferTimes(zakat.StrictDataclass, list[zakat.zakat_tracker.TransferTime]):
728@dataclasses.dataclass
729class TransferTimes(StrictDataclass, list[TransferTime]):
730    """A list of TransferTime objects."""
731    pass

A list of TransferTime objects.

@dataclasses.dataclass
class TransferRecord(zakat.StrictDataclass):
734@dataclasses.dataclass
735class TransferRecord(StrictDataclass):
736    """
737    Represents a transfer record.
738
739    Attributes:
740    - box_ref: The timestamp reference for the box.
741    - times: A list of TransferTime objects.
742    """
743    box_ref: Timestamp
744    times: TransferTimes

Represents a transfer record.

Attributes:

  • box_ref: The timestamp reference for the box.
  • times: A list of TransferTime objects.
TransferRecord( box_ref: Timestamp, times: TransferTimes)
box_ref: Timestamp
times: TransferTimes
class TransferReport(zakat.StrictDataclass, list[zakat.zakat_tracker.TransferRecord]):
747class TransferReport(StrictDataclass, list[TransferRecord]):
748    """A list of TransferRecord objects."""
749    pass

A list of TransferRecord objects.

@dataclasses.dataclass
class BoxPlan(zakat.StrictDataclass):
558@dataclasses.dataclass
559class BoxPlan(StrictDataclass):
560    """
561    Represents a plan for a box.
562
563    Attributes:
564    - box: The Box object.
565    - log: The Log object.
566    - exchange: The Exchange object.
567    - below_nisab: A boolean indicating whether the value is below nisab.
568    - total: The total value.
569    - count: The count.
570    - ref: The timestamp reference for related Box & Log.
571    """
572    box: Box
573    log: Log
574    exchange: Exchange
575    below_nisab: bool
576    total: float
577    count: int
578    ref: Timestamp

Represents a plan for a box.

Attributes:

  • box: The Box object.
  • log: The Log object.
  • exchange: The Exchange object.
  • below_nisab: A boolean indicating whether the value is below nisab.
  • total: The total value.
  • count: The count.
  • ref: The timestamp reference for related Box & Log.
BoxPlan( box: Box, log: Log, exchange: Exchange, below_nisab: bool, total: float, count: int, ref: Timestamp)
box: Box
log: Log
exchange: Exchange
below_nisab: bool
total: float
count: int
ref: Timestamp
@dataclasses.dataclass
class ZakatSummary(zakat.StrictDataclass):
581@dataclasses.dataclass
582class ZakatSummary(StrictDataclass):
583    """
584    Summarizes key financial figures for a Zakat calculation.
585
586    Attributes:
587    - total_wealth (int): The total wealth collected from all rest of transactions.
588    - num_wealth_items (int): The number of individual transactions contributing to the total wealth.
589    - num_zakatable_items (int): The number of transactions subject to Zakat.
590    - total_zakatable_amount (int): The total value of all transactions subject to Zakat.
591    - total_zakat_due (int): The calculated amount of Zakat payable.
592    """
593    total_wealth: int = 0
594    num_wealth_items: int = 0
595    num_zakatable_items: int = 0
596    total_zakatable_amount: int = 0
597    total_zakat_due: int = 0

Summarizes key financial figures for a Zakat calculation.

Attributes:

  • total_wealth (int): The total wealth collected from all rest of transactions.
  • num_wealth_items (int): The number of individual transactions contributing to the total wealth.
  • num_zakatable_items (int): The number of transactions subject to Zakat.
  • total_zakatable_amount (int): The total value of all transactions subject to Zakat.
  • total_zakat_due (int): The calculated amount of Zakat payable.
ZakatSummary( total_wealth: int = 0, num_wealth_items: int = 0, num_zakatable_items: int = 0, total_zakatable_amount: int = 0, total_zakat_due: int = 0)
total_wealth: int = 0
num_wealth_items: int = 0
num_zakatable_items: int = 0
total_zakatable_amount: int = 0
total_zakat_due: int = 0
@dataclasses.dataclass
class ZakatReport(zakat.StrictDataclass):
600@dataclasses.dataclass
601class ZakatReport(StrictDataclass):
602    """
603    Represents a Zakat report containing the calculation summary, plan, and parameters.
604
605    Attributes:
606    - created: The timestamp when the report was created.
607    - valid: A boolean indicating whether the Zakat is available.
608    - summary: The ZakatSummary object.
609    - plan: A dictionary mapping account IDs to lists of BoxPlan objects.
610    - parameters: A dictionary holding the input parameters used during the Zakat calculation.
611    """
612    created: Timestamp
613    valid: bool
614    summary: ZakatSummary
615    plan: dict[AccountID, list[BoxPlan]]
616    parameters: dict

Represents a Zakat report containing the calculation summary, plan, and parameters.

Attributes:

  • created: The timestamp when the report was created.
  • valid: A boolean indicating whether the Zakat is available.
  • summary: The ZakatSummary object.
  • plan: A dictionary mapping account IDs to lists of BoxPlan objects.
  • parameters: A dictionary holding the input parameters used during the Zakat calculation.
ZakatReport( created: Timestamp, valid: bool, summary: ZakatSummary, plan: dict[AccountID, list[BoxPlan]], parameters: dict)
created: Timestamp
valid: bool
summary: ZakatSummary
plan: dict[AccountID, list[BoxPlan]]
parameters: dict
def test(path: Optional[str] = None, debug: bool = False):
5561def test(path: Optional[str] = None, debug: bool = False):
5562    """
5563    Executes a test suite for the ZakatTracker.
5564
5565    This function initializes a ZakatTracker instance, optionally using a specified
5566    database path or a temporary directory. It then runs the test suite and, if debug
5567    mode is enabled, prints detailed test results and execution time.
5568
5569    Parameters:
5570    - path (str, optional): The path to the ZakatTracker database. If None, a
5571                            temporary directory is created. Defaults to None.
5572    - debug (bool, optional): Enables debug mode, which prints detailed test
5573                            results and execution time. Defaults to False.
5574
5575    Returns:
5576    None. The function asserts the result of the ZakatTracker's test suite.
5577
5578    Raises:
5579    - AssertionError: If the ZakatTracker's test suite fails.
5580
5581    Examples:
5582    - `test()` Runs tests using a temporary database.
5583    - `test(debug=True)` Runs the test suite in debug mode with a temporary directory.
5584    - `test(path="/path/to/my/db")` Runs tests using a specified database path.
5585    - `test(path="/path/to/my/db", debug=False)` Runs test suite with specified path.
5586    """
5587    no_path = path is None
5588    if no_path:
5589        path = tempfile.mkdtemp()
5590        print(f"Random database path {path}")
5591    if os.path.exists(path):
5592        shutil.rmtree(path)
5593    assert ZakatTracker(':memory:').memory_mode()
5594    ledger = ZakatTracker(
5595        db_path=path,
5596        history_mode=True,
5597    )
5598    start = time.time_ns()
5599    assert not ledger.memory_mode()
5600    assert ledger.test(debug=debug)
5601    if no_path and os.path.exists(path):
5602        shutil.rmtree(path)
5603    if debug:
5604        print('#########################')
5605        print('######## TEST DONE ########')
5606        print('#########################')
5607        print(Time.duration_from_nanoseconds(time.time_ns() - start))
5608        print('#########################')

Executes a test suite for the ZakatTracker.

This function initializes a ZakatTracker instance, optionally using a specified database path or a temporary directory. It then runs the test suite and, if debug mode is enabled, prints detailed test results and execution time.

Parameters:

  • path (str, optional): The path to the ZakatTracker database. If None, a temporary directory is created. Defaults to None.
  • debug (bool, optional): Enables debug mode, which prints detailed test results and execution time. Defaults to False.

Returns: None. The function asserts the result of the ZakatTracker's test suite.

Raises:

  • AssertionError: If the ZakatTracker's test suite fails.

Examples:

  • test() Runs tests using a temporary database.
  • test(debug=True) Runs the test suite in debug mode with a temporary directory.
  • test(path="/path/to/my/db") Runs tests using a specified database path.
  • test(path="/path/to/my/db", debug=False) Runs test suite with specified path.
@enum.unique
class Action(enum.Enum):
124@enum.unique
125class Action(enum.Enum):
126    """
127    Enumeration representing various actions that can be performed.
128
129    Members:
130    - CREATE: Represents the creation action ('CREATE').
131    - NAME: Represents the renaming action ('NAME').
132    - TRACK: Represents the tracking action ('TRACK').
133    - LOG: Represents the logging action ('LOG').
134    - SUBTRACT: Represents the subtract action ('SUBTRACT').
135    - ADD_FILE: Represents the action of adding a file ('ADD_FILE').
136    - REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE').
137    - BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER').
138    - EXCHANGE: Represents the exchange action ('EXCHANGE').
139    - REPORT: Represents the reporting action ('REPORT').
140    - ZAKAT: Represents a Zakat related action ('ZAKAT').
141    """
142    CREATE = 'CREATE'
143    NAME = 'NAME'
144    TRACK = 'TRACK'
145    LOG = 'LOG'
146    SUBTRACT = 'SUBTRACT'
147    ADD_FILE = 'ADD_FILE'
148    REMOVE_FILE = 'REMOVE_FILE'
149    BOX_TRANSFER = 'BOX_TRANSFER'
150    EXCHANGE = 'EXCHANGE'
151    REPORT = 'REPORT'
152    ZAKAT = 'ZAKAT'

Enumeration representing various actions that can be performed.

Members:

  • CREATE: Represents the creation action ('CREATE').
  • NAME: Represents the renaming action ('NAME').
  • TRACK: Represents the tracking action ('TRACK').
  • LOG: Represents the logging action ('LOG').
  • SUBTRACT: Represents the subtract action ('SUBTRACT').
  • ADD_FILE: Represents the action of adding a file ('ADD_FILE').
  • REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE').
  • BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER').
  • EXCHANGE: Represents the exchange action ('EXCHANGE').
  • REPORT: Represents the reporting action ('REPORT').
  • ZAKAT: Represents a Zakat related action ('ZAKAT').
CREATE = <Action.CREATE: 'CREATE'>
NAME = <Action.NAME: 'NAME'>
TRACK = <Action.TRACK: 'TRACK'>
LOG = <Action.LOG: 'LOG'>
SUBTRACT = <Action.SUBTRACT: 'SUBTRACT'>
ADD_FILE = <Action.ADD_FILE: 'ADD_FILE'>
REMOVE_FILE = <Action.REMOVE_FILE: 'REMOVE_FILE'>
BOX_TRANSFER = <Action.BOX_TRANSFER: 'BOX_TRANSFER'>
EXCHANGE = <Action.EXCHANGE: 'EXCHANGE'>
REPORT = <Action.REPORT: 'REPORT'>
ZAKAT = <Action.ZAKAT: 'ZAKAT'>
class JSONEncoder(json.encoder.JSONEncoder):
901class JSONEncoder(json.JSONEncoder):
902    """
903    Custom JSON encoder to handle specific object types.
904
905    This encoder overrides the default `default` method to serialize:
906    - `Action` and `MathOperation` enums as their member names.
907    - `decimal.Decimal` instances as floats.
908
909    Example:
910    ```bash
911    >>> json.dumps(Action.CREATE, cls=JSONEncoder)
912    'CREATE'
913    >>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder)
914    '10.5'
915    ```
916    """
917    def default(self, o):
918        """
919        Overrides the default `default` method to serialize specific object types.
920
921        Parameters:
922        - o: The object to serialize.
923
924        Returns:
925        - The serialized object.
926        """
927        if isinstance(o, (Action, MathOperation)):
928            return o.name  # Serialize as the enum member's name
929        if isinstance(o, decimal.Decimal):
930            return float(o)
931        if isinstance(o, Exception):
932            return str(o)
933        if isinstance(o, Vault) or isinstance(o, ImportReport):
934            return dataclasses.asdict(o)
935        return super().default(o)

Custom JSON encoder to handle specific object types.

This encoder overrides the default default method to serialize:

Example:

>>> json.dumps(Action.CREATE, cls=JSONEncoder)
'CREATE'
>>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder)
'10.5'
def default(self, o):
917    def default(self, o):
918        """
919        Overrides the default `default` method to serialize specific object types.
920
921        Parameters:
922        - o: The object to serialize.
923
924        Returns:
925        - The serialized object.
926        """
927        if isinstance(o, (Action, MathOperation)):
928            return o.name  # Serialize as the enum member's name
929        if isinstance(o, decimal.Decimal):
930            return float(o)
931        if isinstance(o, Exception):
932            return str(o)
933        if isinstance(o, Vault) or isinstance(o, ImportReport):
934            return dataclasses.asdict(o)
935        return super().default(o)

Overrides the default default method to serialize specific object types.

Parameters:

  • o: The object to serialize.

Returns:

  • The serialized object.
class JSONDecoder(json.decoder.JSONDecoder):
938class JSONDecoder(json.JSONDecoder):
939    """
940    Custom JSON decoder to handle specific object types.
941
942    This decoder overrides the `object_hook` method to deserialize:
943    - Strings representing enum member names back to their respective enum values.
944    - Floats back to `decimal.Decimal` instances.
945
946    Example:
947    ```bash
948    >>> json.loads('{"action": "CREATE"}', cls=JSONDecoder)
949    {'action': <Action.CREATE: 1>}
950    >>> json.loads('{"value": 10.5}', cls=JSONDecoder)
951    {'value': Decimal('10.5')}
952    ```
953    """
954    def object_hook(self, obj):
955        """
956        Overrides the default `object_hook` method to deserialize specific object types.
957
958        Parameters:
959        - obj: The object to deserialize.
960
961        Returns:
962        - The deserialized object.
963        """
964        if isinstance(obj, str) and obj in Action.__members__:
965            return Action[obj]
966        if isinstance(obj, str) and obj in MathOperation.__members__:
967            return MathOperation[obj]
968        if isinstance(obj, float):
969            return decimal.Decimal(str(obj))
970        return obj

Custom JSON decoder to handle specific object types.

This decoder overrides the object_hook method to deserialize:

  • Strings representing enum member names back to their respective enum values.
  • Floats back to decimal.Decimal instances.

Example:

>>> json.loads('{"action": "CREATE"}', cls=JSONDecoder)
{'action': <Action.CREATE: 1>}
>>> json.loads('{"value": 10.5}', cls=JSONDecoder)
{'value': Decimal('10.5')}
def object_hook(self, obj):
954    def object_hook(self, obj):
955        """
956        Overrides the default `object_hook` method to deserialize specific object types.
957
958        Parameters:
959        - obj: The object to deserialize.
960
961        Returns:
962        - The deserialized object.
963        """
964        if isinstance(obj, str) and obj in Action.__members__:
965            return Action[obj]
966        if isinstance(obj, str) and obj in MathOperation.__members__:
967            return MathOperation[obj]
968        if isinstance(obj, float):
969            return decimal.Decimal(str(obj))
970        return obj

Overrides the default object_hook method to deserialize specific object types.

Parameters:

  • obj: The object to deserialize.

Returns:

  • The deserialized object.
@enum.unique
class MathOperation(enum.Enum):
155@enum.unique
156class MathOperation(enum.Enum):
157    """
158    Enumeration representing mathematical operations.
159
160    Members:
161    - ADDITION: Represents the addition operation ('ADDITION').
162    - EQUAL: Represents the equality operation ('EQUAL').
163    - SUBTRACTION: Represents the subtraction operation ('SUBTRACTION').
164    """
165    ADDITION = 'ADDITION'
166    EQUAL = 'EQUAL'
167    SUBTRACTION = 'SUBTRACTION'

Enumeration representing mathematical operations.

Members:

  • ADDITION: Represents the addition operation ('ADDITION').
  • EQUAL: Represents the equality operation ('EQUAL').
  • SUBTRACTION: Represents the subtraction operation ('SUBTRACTION').
ADDITION = <MathOperation.ADDITION: 'ADDITION'>
EQUAL = <MathOperation.EQUAL: 'EQUAL'>
SUBTRACTION = <MathOperation.SUBTRACTION: 'SUBTRACTION'>
@enum.unique
class WeekDay(enum.Enum):
101@enum.unique
102class WeekDay(enum.Enum):
103    """
104    Enumeration representing the days of the week.
105
106    Members:
107    - MONDAY: Represents Monday (0).
108    - TUESDAY: Represents Tuesday (1).
109    - WEDNESDAY: Represents Wednesday (2).
110    - THURSDAY: Represents Thursday (3).
111    - FRIDAY: Represents Friday (4).
112    - SATURDAY: Represents Saturday (5).
113    - SUNDAY: Represents Sunday (6).
114    """
115    MONDAY = 0
116    TUESDAY = 1
117    WEDNESDAY = 2
118    THURSDAY = 3
119    FRIDAY = 4
120    SATURDAY = 5
121    SUNDAY = 6

Enumeration representing the days of the week.

Members:

  • MONDAY: Represents Monday (0).
  • TUESDAY: Represents Tuesday (1).
  • WEDNESDAY: Represents Wednesday (2).
  • THURSDAY: Represents Thursday (3).
  • FRIDAY: Represents Friday (4).
  • SATURDAY: Represents Saturday (5).
  • SUNDAY: Represents Sunday (6).
MONDAY = <WeekDay.MONDAY: 0>
TUESDAY = <WeekDay.TUESDAY: 1>
WEDNESDAY = <WeekDay.WEDNESDAY: 2>
THURSDAY = <WeekDay.THURSDAY: 3>
FRIDAY = <WeekDay.FRIDAY: 4>
SATURDAY = <WeekDay.SATURDAY: 5>
SUNDAY = <WeekDay.SUNDAY: 6>
def start_file_server( database_path: str, database_callback: Optional[<built-in function callable>] = None, csv_callback: Optional[<built-in function callable>] = None, debug: bool = False) -> tuple:
112def start_file_server(database_path: str, database_callback: Optional[callable] = None, csv_callback: Optional[callable] = None,
113                      debug: bool = False) -> tuple:
114    """
115    Starts a multi-purpose WSGI server to manage file interactions for a Zakat application.
116
117    This server facilitates the following functionalities:
118
119    1. GET `/{file_uuid}/get`: Download the database file specified by `database_path`.
120    2. GET `/{file_uuid}/upload`: Display an HTML form for uploading files.
121    3. POST `/{file_uuid}/upload`: Handle file uploads, distinguishing between:
122        - Database File (.db): Replaces the existing database with the uploaded one.
123        - CSV File (.csv): Imports data from the CSV into the existing database.
124
125    Parameters:
126    - database_path (str): The path to the camel database file.
127    - database_callback (callable, optional): A function to call after a successful database upload.
128                                                It receives the uploaded database path as its argument.
129    - csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path,
130                                           the database path, and the debug flag as its arguments.
131    - debug (bool, optional): If True, print debugging information. Defaults to False.
132
133    Returns:
134    - Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing:
135        - file_name (str): The name of the database file.
136        - download_url (str): The URL to download the database file.
137        - upload_url (str): The URL to access the file upload form.
138        - server_thread (threading.Thread): The thread running the server.
139        - shutdown_server (Callable[[], None]): A function to gracefully shut down the server.
140
141    Example:
142    ```python
143    _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db")
144    print(f"Download database: {download_url}")
145    print(f"Upload files: {upload_url}")
146    server_thread.start()
147    # ... later ...
148    shutdown_server()
149    ```
150    """
151    file_uuid = uuid.uuid4()
152    file_name = os.path.basename(database_path)
153
154    port = find_available_port()
155    download_url = f"http://localhost:{port}/{file_uuid}/get"
156    upload_url = f"http://localhost:{port}/{file_uuid}/upload"
157
158    # Upload directory
159    upload_directory = "./uploads"
160    os.makedirs(upload_directory, exist_ok=True)
161
162    # HTML templates
163    upload_form = f"""
164    <html lang="en">
165        <head>
166            <title>Zakat File Server</title>
167        </head>
168    <body>
169    <h1>Zakat File Server</h1>
170    <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3>
171    <h3>Or upload a new file to restore a database or import `CSV` file:</h3>
172    <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data">
173        <input type="file" name="file" required><br/>
174        <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required>
175        <label for="database">Database File</label><br/>
176        <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}">
177        <label for="csv">CSV File</label><br/>
178        <input type="submit" value="Upload"><br/>
179    </form>
180    </body></html>
181    """
182
183    # WSGI application
184    def wsgi_app(environ, start_response):
185        path = environ.get('PATH_INFO', '')
186        method = environ.get('REQUEST_METHOD', 'GET')
187
188        if path == f"/{file_uuid}/get" and method == 'GET':
189            # GET: Serve the existing file
190            try:
191                with open(database_path, "rb") as f:
192                    file_content = f.read()
193                    
194                start_response('200 OK', [
195                    ('Content-type', 'application/octet-stream'),
196                    ('Content-Disposition', f'attachment; filename="{file_name}"'),
197                    ('Content-Length', str(len(file_content)))
198                ])
199                return [file_content]
200            except FileNotFoundError:
201                start_response('404 Not Found', [('Content-type', 'text/plain')])
202                return [b'File not found']
203                
204        elif path == f"/{file_uuid}/upload" and method == 'GET':
205            # GET: Serve the upload form
206            start_response('200 OK', [('Content-type', 'text/html')])
207            return [upload_form.encode()]
208            
209        elif path == f"/{file_uuid}/upload" and method == 'POST':
210            # POST: Handle file uploads
211            try:
212                # Get content length
213                content_length = int(environ.get('CONTENT_LENGTH', 0))
214                
215                # Get content type and boundary
216                content_type = environ.get('CONTENT_TYPE', '')
217                
218                # Read the request body
219                request_body = environ['wsgi.input'].read(content_length)
220                
221                # Create a file-like object from the request body
222                # request_body_file = io.BytesIO(request_body)
223                
224                # Parse the multipart form data using WSGI approach
225                # First, detect the boundary from content_type
226                boundary = None
227                for part in content_type.split(';'):
228                    part = part.strip()
229                    if part.startswith('boundary='):
230                        boundary = part[9:]
231                        if boundary.startswith('"') and boundary.endswith('"'):
232                            boundary = boundary[1:-1]
233                        break
234                
235                if not boundary:
236                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
237                    return [b"Missing boundary in multipart form data"]
238                
239                # Process multipart data
240                parts = request_body.split(f'--{boundary}'.encode())
241                
242                # Initialize variables to store form data
243                upload_type = None
244                # file_item = None
245                file_data = None
246                filename = None
247                
248                # Process each part
249                for part in parts:
250                    if not part.strip():
251                        continue
252                    
253                    # Split header and body
254                    try:
255                        headers_raw, body = part.split(b'\r\n\r\n', 1)
256                        headers_text = headers_raw.decode('utf-8')
257                    except ValueError:
258                        continue
259                    
260                    # Parse headers
261                    headers = {}
262                    for header_line in headers_text.split('\r\n'):
263                        if ':' in header_line:
264                            name, value = header_line.split(':', 1)
265                            headers[name.strip().lower()] = value.strip()
266                    
267                    # Get content disposition
268                    content_disposition = headers.get('content-disposition', '')
269                    if not content_disposition.startswith('form-data'):
270                        continue
271                    
272                    # Extract field name
273                    field_name = None
274                    for item in content_disposition.split(';'):
275                        item = item.strip()
276                        if item.startswith('name='):
277                            field_name = item[5:].strip('"\'')
278                            break
279                    
280                    if not field_name:
281                        continue
282                    
283                    # Handle upload_type field
284                    if field_name == 'upload_type':
285                        # Remove trailing data including the boundary
286                        body_end = body.find(b'\r\n--')
287                        if body_end >= 0:
288                            body = body[:body_end]
289                        upload_type = body.decode('utf-8').strip()
290                    
291                    # Handle file field
292                    elif field_name == 'file':
293                        # Extract filename
294                        for item in content_disposition.split(';'):
295                            item = item.strip()
296                            if item.startswith('filename='):
297                                filename = item[9:].strip('"\'')
298                                break
299                        
300                        if filename:
301                            # Remove trailing data including the boundary
302                            body_end = body.find(b'\r\n--')
303                            if body_end >= 0:
304                                body = body[:body_end]
305                            file_data = body
306                
307                if debug:
308                    print('upload_type', upload_type)
309                    
310                if debug:
311                    print('upload_type:', upload_type)
312                    print('filename:', filename)
313                
314                if not upload_type or upload_type not in [FileType.Database.value, FileType.CSV.value]:
315                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
316                    return [b"Invalid upload type"]
317                
318                if not filename or not file_data:
319                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
320                    return [b"Missing file data"]
321                
322                if debug:
323                    print(f'Uploaded filename: {filename}')
324                
325                # Save the file
326                file_path = os.path.join(upload_directory, upload_type)
327                with open(file_path, 'wb') as f:
328                    f.write(file_data)
329                
330                # Process based on file type
331                if upload_type == FileType.Database.value:
332                    try:
333                        # Verify database file
334                        if database_callback is not None:
335                            database_callback(file_path)
336                        
337                        # Copy database into the original path
338                        shutil.copy2(file_path, database_path)
339                        
340                        start_response('200 OK', [('Content-type', 'text/plain')])
341                        return [b"Database file uploaded successfully."]
342                    except Exception as e:
343                        start_response('400 Bad Request', [('Content-type', 'text/plain')])
344                        return [str(e).encode()]
345                
346                elif upload_type == FileType.CSV.value:
347                    try:
348                        if csv_callback is not None:
349                            result = csv_callback(file_path, database_path, debug)
350                            if debug:
351                                print(f'CSV imported: {result}')
352                            if len(result[2]) != 0:
353                                start_response('200 OK', [('Content-type', 'application/json')])
354                                return [json.dumps(result).encode()]
355                        
356                        start_response('200 OK', [('Content-type', 'text/plain')])
357                        return [b"CSV file uploaded successfully."]
358                    except Exception as e:
359                        start_response('400 Bad Request', [('Content-type', 'text/plain')])
360                        return [str(e).encode()]
361            
362            except Exception as e:
363                start_response('500 Internal Server Error', [('Content-type', 'text/plain')])
364                return [f"Error processing upload: {str(e)}".encode()]
365        
366        else:
367            # 404 for anything else
368            start_response('404 Not Found', [('Content-type', 'text/plain')])
369            return [b'Not Found']
370    
371    # Create and start the server
372    httpd = make_server('localhost', port, wsgi_app)
373    server_thread = threading.Thread(target=httpd.serve_forever)
374    
375    def shutdown_server():
376        nonlocal httpd, server_thread
377        httpd.shutdown()
378        server_thread.join()  # Wait for the thread to finish
379    
380    return file_name, download_url, upload_url, server_thread, shutdown_server

Starts a multi-purpose WSGI 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.

Parameters:

  • database_path (str): The path to the camel 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:
 87def find_available_port() -> int:
 88    """
 89    Finds and returns an available TCP port on the local machine.
 90
 91    This function utilizes a TCP server socket to bind to port 0, which
 92    instructs the operating system to automatically assign an available
 93    port. The assigned port is then extracted and returned.
 94
 95    Returns:
 96    - int: The available TCP port number.
 97
 98    Raises:
 99    - OSError: If an error occurs during the port binding process, such
100            as all ports being in use.
101
102    Example:
103    ```python
104    port = find_available_port()
105    print(f"Available port: {port}")
106    ```
107    """
108    with socketserver.TCPServer(("localhost", 0), None) as s:
109        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}")
@enum.unique
class FileType(enum.Enum):
42@enum.unique
43class FileType(enum.Enum):
44    """
45    Enumeration representing file types.
46
47    Members:
48    - Database: Represents a database file ('db').
49    - CSV: Represents a CSV file ('csv').
50    """
51    Database = 'db'
52    CSV = 'csv'

Enumeration representing file types.

Members:

  • Database: Represents a database file ('db').
  • CSV: Represents a CSV file ('csv').
Database = <FileType.Database: 'db'>
CSV = <FileType.CSV: 'csv'>
@dataclasses.dataclass
class StrictDataclass:
374@dataclasses.dataclass
375class StrictDataclass:
376    """A dataclass that prevents setting non-existent attributes."""
377    def __setattr__(self, name: str, value: any) -> None:
378        _check_attribute(self, name, value)

A dataclass that prevents setting non-existent attributes.

class ImmutableWithSelectiveFreeze:
381class ImmutableWithSelectiveFreeze:
382    """
383    A base class for creating immutable objects with the ability to selectively
384    freeze specific fields.
385
386    Inheriting from this class will automatically make all fields defined in
387    dataclasses as frozen after initialization if their metadata contains
388    `"frozen": True`. Attempting to set a value to a frozen field after
389    initialization will raise a RuntimeError.
390
391    Example:
392    ```python
393    @dataclasses.dataclass
394    class MyObject(ImmutableWithSelectiveFreeze):
395        name: str
396        count: int = dataclasses.field(metadata={"frozen": True})
397        description: str = "default"
398
399    obj = MyObject(name="Test", count=5)
400    print(obj.name)  # Output: Test
401    print(obj.count) # Output: 5
402    obj.name = "New Name" # This will work
403    try:
404        obj.count = 10  # This will raise a RuntimeError
405    except RuntimeError as e:
406        print(e)      # Output: Field 'count' is frozen!
407    print(obj.description) # Output: default
408    obj.description = "updated" # This will work
409    ```
410    """
411    # Implementation based on: https://discuss.python.org/t/dataclasses-freezing-specific-fields-should-be-possible/59968/2
412    def __post_init__(self):
413        """
414        Initializes the object and freezes fields marked with `"frozen": True`
415        in their metadata.
416        """
417        self.__set_fields_frozen(self)
418
419    @classmethod
420    def __set_fields_frozen(cls, self):
421        """
422        Iterates through the dataclass fields and freezes those with the
423        `"frozen": True` metadata.
424        """
425        flds = dataclasses.fields(cls)
426        for fld in flds:
427            if fld.metadata.get("frozen"):
428                field_name = fld.name
429                field_value = getattr(self, fld.name)
430                setattr(self, f"_{fld.name}", field_value)
431
432                def local_getter(self):
433                    """Getter for the frozen field."""
434                    return getattr(self, f"_{field_name}")
435
436                def frozen(name):
437                    """Creates a setter that raises a RuntimeError for frozen fields."""
438                    def local_setter(self, value):
439                        raise RuntimeError(f"Field '{name}' is frozen!")
440                    return local_setter
441
442                setattr(cls, field_name, property(local_getter, frozen(field_name)))

A base class for creating immutable objects with the ability to selectively freeze specific fields.

Inheriting from this class will automatically make all fields defined in dataclasses as frozen after initialization if their metadata contains "frozen": True. Attempting to set a value to a frozen field after initialization will raise a RuntimeError.

Example:

@dataclasses.dataclass
class MyObject(ImmutableWithSelectiveFreeze):
    name: str
    count: int = dataclasses.field(metadata={"frozen": True})
    description: str = "default"

obj = MyObject(name="Test", count=5)
print(obj.name)  # Output: Test
print(obj.count) # Output: 5
obj.name = "New Name" # This will work
try:
    obj.count = 10  # This will raise a RuntimeError
except RuntimeError as e:
    print(e)      # Output: Field 'count' is frozen!
print(obj.description) # Output: default
obj.description = "updated" # This will work
@dataclasses.dataclass
class Backup:
888@dataclasses.dataclass
889class Backup:
890    """
891    Represents a backup of a file.
892
893    Attributes:
894    - path (str): The path to the back-up file.
895    - hash (str): The hash (SHA1) of the backed-up data for integrity verification.
896    """
897    path: str
898    hash: str

Represents a backup of a file.

Attributes:

  • path (str): The path to the back-up file.
  • hash (str): The hash (SHA1) of the backed-up data for integrity verification.
Backup(path: str, hash: str)
path: str
hash: str