diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2562f3d..67d1e10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
## Changelog
All notable changes to this project will be documented in this file.
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
\ No newline at end of file
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## 0.2.2-Beta
+- Added a new feature to display old transaction history by CLI
+ e.g. `etracker --month 10 --year 2023`
+- Added `--interval` option to get the transaction data for a specific interval and unit seconds
+ e.g. `etracker --interval 10` # Every 10 seconds
\ No newline at end of file
diff --git a/README.md b/README.md
index 1bbc299..95c6f05 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,8 @@ transactions and summarizing them in daily, monthly, and yearly reports.
- **Database Storage**: Store transaction data in an SQLite database with indexing for faster queries.
- **Reporting**:
- Daily, monthly, and yearly transaction summaries.
+ - Can generate previous month transaction data.
+ - Can add `--interval` option to get the transaction data for a specific interval.
- Total expense calculation per category.
- **Display**: Generate reports in a tabular format using the `tabulate` library.
- **Environment Variables**: Securely configure sensitive data like email credentials and target subjects.
@@ -32,6 +34,7 @@ ExpenseTracker/
├── logs_config.py # Logging configuration
├── display.py # Handles data display
├── gmail_authenticator.py # Gmail OAuth authentication
+ ├── validators.py # Validates user's input
├── tests/ # Unit tests for the application
│ ├── __init__.py
│ ├── test_db.py
@@ -39,7 +42,8 @@ ExpenseTracker/
│ ├── test_email_fetcher.py
│ ├── test_email_parser.py
│ ├── test_etd.py
- │ └── test_tracker.py
+ │ └── test_tracker.py.
+ │ └── test_validators.py.
│
└── README.md # Project documentation
```
diff --git a/tests/test_validators.py b/tests/test_validators.py
new file mode 100644
index 0000000..d3cda24
--- /dev/null
+++ b/tests/test_validators.py
@@ -0,0 +1,112 @@
+import unittest
+from tracker.validators import validate_month_year, validate_args, validate_sender_email
+
+
+class TestValidateSenderEmail(unittest.TestCase):
+ def test_valid_sender_email(self):
+ class DummyClass:
+ sender_email = "test@example.com"
+
+ @validate_sender_email
+ def dummy_function(obj):
+ return "Function executed"
+
+ instance = DummyClass()
+ self.assertEqual(dummy_function(instance), "Function executed")
+
+ def test_missing_sender_email_attribute(self):
+ class DummyClass:
+ pass
+
+ @validate_sender_email
+ def dummy_function(obj):
+ return "Function executed"
+
+ instance = DummyClass()
+ with self.assertRaises(ValueError) as context:
+ dummy_function(instance)
+ self.assertEqual(str(context.exception), "Missing environment variable: ET_SENDER_EMAIL")
+
+ def test_empty_sender_email(self):
+ class DummyClass:
+ sender_email = ""
+
+ @validate_sender_email
+ def dummy_function(obj):
+ return "Function executed"
+
+ instance = DummyClass()
+ with self.assertRaises(ValueError) as context:
+ dummy_function(instance)
+ self.assertEqual(str(context.exception), "Missing environment variable: ET_SENDER_EMAIL")
+
+ def test_invalid_first_argument(self):
+ @validate_sender_email
+ def dummy_function():
+ return "Function executed"
+
+ with self.assertRaises(ValueError) as context:
+ dummy_function()
+
+ self.assertEqual(str(context.exception), "First argument must be an instance of the class.")
+
+
+class TestValidateMonthYear(unittest.TestCase):
+ def test_validate_month_year_valid_inputs(self):
+ @validate_month_year
+ def dummy_function(self, month, year):
+ return f"Month: {month}, Year: {year}"
+
+ self.assertEqual(dummy_function(self, 1, 2023), "Month: 1, Year: 2023")
+ self.assertEqual(dummy_function(self,"January", 2023), "Month: January, Year: 2023")
+
+ def test_validate_month_year_invalid_year(self):
+ @validate_month_year
+ def dummy_function(self,month, year):
+ return f"Month: {month}, Year: {year}"
+
+ with self.assertRaises(ValueError) as context:
+ dummy_function(self, 1, 23)
+ self.assertEqual(str(context.exception), "Invalid year format: '23'. Year must be a 4-digit string.")
+
+ def test_validate_month_year_invalid_month(self):
+ @validate_month_year
+ def dummy_function(self, month, year):
+ return f"Month: {month}, Year: {year}"
+
+ with self.assertRaises(ValueError) as context:
+ dummy_function(self, 13, 2023)
+ self.assertEqual(str(context.exception), "Invalid month: '13'. Must be '01'-'12' or full month name.")
+
+ with self.assertRaises(ValueError) as context:
+ dummy_function(self,"InvalidMonth", 2023)
+ self.assertEqual(str(context.exception), "Invalid month: 'InvalidMonth'. Must be '01'-'12' or full month name.")
+
+ def test_validate_month_year_invalid_month_type(self):
+ @validate_month_year
+ def dummy_function(self, month, year):
+ return f"Month: {month}, Year: {year}"
+
+ with self.assertRaises(ValueError) as context:
+ dummy_function(self,[], 2023)
+ self.assertEqual(str(context.exception), "Month must be a string. Got instead.")
+
+
+class TestValidateArgs(unittest.TestCase):
+ def test_validate_args_with_interval(self):
+ args = {"interval": True}
+ self.assertEqual(validate_args(args), ("continuous", None))
+
+ def test_validate_args_with_month_and_year(self):
+ args = {"month": "January", "year": "2023"}
+ self.assertEqual(validate_args(args), ("monthly", None))
+
+ def test_validate_args_with_interval_and_month(self):
+ args = {"interval": True, "month": "January"}
+ self.assertEqual(validate_args(args), ("error", "Cannot use --interval with --month/--year together."))
+
+ def test_validate_args_with_no_valid_combination(self):
+ args = {}
+ self.assertEqual(validate_args(args), ("continuous", None))
+
+
diff --git a/tracker/__version__.py b/tracker/__version__.py
index 767a002..b9314d5 100644
--- a/tracker/__version__.py
+++ b/tracker/__version__.py
@@ -1,4 +1,4 @@
"""
Expense Tracker version
"""
-__version__ = "0.1.1-Beta"
+__version__ = "0.2.2-Beta"
diff --git a/tracker/expense_tracker.py b/tracker/expense_tracker.py
index 9bf5073..c51e645 100644
--- a/tracker/expense_tracker.py
+++ b/tracker/expense_tracker.py
@@ -1,7 +1,9 @@
+import argparse
import datetime
import os
import time
+from tracker.validators import validate_month_year, validate_args, validate_sender_email
from tracker.logs_config import logger
from tracker.gmail_authenticator import GmailAuthenticator
from tracker.email_fetcher import EmailFetcher
@@ -33,13 +35,12 @@ def __init__(self):
self.sender_email = os.getenv("ET_SENDER_EMAIL")
self.target_subjects = os.getenv("ET_TARGET_SUBJECTS", DEFAULT_SUBJECTS).split(",")
- self.validate_env_variables()
-
def validate_env_variables(self):
if not self.sender_email:
logger.error("Missing ET_SENDER_EMAIL. Please set this environment variable.")
raise ValueError("Missing environment variable: ET_SENDER_EMAIL")
+ @validate_sender_email
def run(self):
try:
messages = self.email_fetcher.filter_unread_messages(self.sender_email, self.target_subjects)
@@ -49,8 +50,8 @@ def run(self):
return
for message in messages:
- parser = EmailParser(message=message)
- data = parser.extract_tags_values_from_body()
+ email_parser = EmailParser(message=message)
+ data = email_parser.extract_tags_values_from_body()
if self.process_data(data):
self.email_fetcher.mark_message_as_read(message.get("id"))
@@ -74,19 +75,50 @@ def show(self):
res = self.db.generate_monthly_report(now.year, now.month)
self.display.display_summary(res)
+ @validate_month_year
+ def get_monthly_summary(self, month, year) -> None:
+ self.display.display_summary(self.db.generate_monthly_report(year, month))
+
def close(self):
self.db.close_connection()
-if __name__ == '__main__':
+def run_continuous(interval: int = 3600):
expense = ExpenseTracker()
try:
while True:
expense.show()
expense.run()
- time.sleep(3600) # An hour
- expense.show() # Show the summary again
+ time.sleep(interval) # An hour
+ expense.show() # Show the summary again
except KeyboardInterrupt:
logger.info("Process interrupted. Exiting...")
finally:
expense.close()
+
+
+def run_monthly_summary(month, year):
+ expense = ExpenseTracker()
+ try:
+ expense.get_monthly_summary(month, year)
+ except Exception as err:
+ logger.error(f"Error generating summary: {err}")
+ finally:
+ expense.close()
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description="Expense Tracker CLI")
+ parser.add_argument('--interval', type=int, help='Run continuously every N seconds', default=argparse.SUPPRESS)
+ parser.add_argument('--month', type=int, help='Month for summary (1–12)', default=argparse.SUPPRESS)
+ parser.add_argument('--year', type=int, help='Year for summary (e.g., 2024)', default=argparse.SUPPRESS)
+
+ args = parser.parse_args()
+ mode, error = validate_args(args)
+
+ if mode == "continuous":
+ run_continuous(args.interval) if hasattr(args, "interval") else run_continuous()
+ elif mode == "monthly":
+ run_monthly_summary(args.month, args.year)
+ else:
+ parser.error("Invalid combination of arguments. Use --interval or --month and --year.")
diff --git a/tracker/validators.py b/tracker/validators.py
new file mode 100644
index 0000000..026ceee
--- /dev/null
+++ b/tracker/validators.py
@@ -0,0 +1,127 @@
+import functools
+import calendar
+
+
+def validate_sender_email(func):
+ """
+ A decorator to validate the presence of a `sender_email` attribute in the first argument of the wrapped function.
+
+ This decorator ensures that:
+ - The first argument passed to the function is an instance of a class.
+ - The instance has a `sender_email` attribute with a non-empty value.
+
+ Args:
+ func (callable): The function to be wrapped by the decorator.
+
+ Returns:
+ callable: The wrapped function with validation applied to the `sender_email` attribute.
+
+ Raises:
+ ValueError: If the first argument is not an instance of a class or if the `sender_email` attribute is missing or empty.
+
+ Notes:
+ - The `sender_email` attribute is expected to be present in the first argument of the wrapped function.
+ - If the validation fails, a `ValueError` is raised with an appropriate error message.
+ """
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ instance = args[0] if args else None
+ if instance is None or not hasattr(instance, "__class__"):
+ raise ValueError("First argument must be an instance of the class.")
+
+ sender_email = instance.sender_email if hasattr(instance, 'sender_email') else None
+ if not sender_email:
+ raise ValueError("Missing environment variable: ET_SENDER_EMAIL")
+ return func(*args, **kwargs)
+ return wrapper
+
+
+def validate_month_year(func):
+ """
+ A decorator to validate the `month` and `year` arguments passed to a function.
+
+ This decorator ensures that:
+ - The `year` argument is a 4-digit integer.
+ - The `month` argument is either an integer between 1 and 12 or a valid full month name (case-insensitive).
+
+ Args:
+ func (callable): The function to be wrapped by the decorator.
+
+ Returns:
+ callable: The wrapped function with validation applied to its `month` and `year` arguments.
+
+ Raises:
+ ValueError: If the `month` or `year` arguments are invalid.
+
+ Notes:
+ - The `month` and `year` arguments can be passed as keyword arguments (`kwargs`) or positional arguments (`args`).
+ - If the validation fails, a `ValueError` is raised with an appropriate error message.
+ """
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ # Get month and year from kwargs or args
+ month = kwargs.get('month') or (args[1] if len(args) > 0 else None)
+ year = kwargs.get('year') or (args[2] if len(args) > 1 else None)
+
+ # Validate year
+ if not isinstance(year, int) or len(str(year)) != 4:
+ raise ValueError(f"Invalid year format: '{year}'. Year must be a 4-digit string.")
+
+ # Validate month
+ valid_months = [i for i in range(1, 13)]
+ valid_month_names = [m.lower() for m in calendar.month_name if m]
+
+ if isinstance(month, int):
+ if month not in valid_months:
+ raise ValueError(f"Invalid month: '{month}'. Must be '01'-'12' or full month name.")
+ elif isinstance(month, str):
+ if month.lower() not in valid_month_names:
+ raise ValueError(f"Invalid month: '{month}'. Must be '01'-'12' or full month name.")
+ else:
+ raise ValueError(f"Month must be a string. Got {type(month)} instead.")
+
+ return func(*args, **kwargs)
+
+ return wrapper
+
+
+def validate_args(args) -> tuple:
+ """
+ Validates the combination of arguments provided to the function.
+
+ Args:
+ args: An object containing the following attributes:
+ - interval (bool): Indicates if the interval mode is enabled.
+ - month (str or None): The month value, expected as a string.
+ - year (str or None): The year value, expected as a string.
+
+ Returns:
+ tuple: A tuple containing:
+ - str: The validation result, which can be one of:
+ - "error": Indicates an invalid combination of arguments.
+ - "continuous": Indicates the interval mode is valid.
+ - "monthly": Indicates the month and year combination is valid.
+ - str or None: An error message if the result is "error", otherwise None.
+
+ Raises:
+ ValueError: If the arguments do not meet the required conditions.
+
+ Notes:
+ - The function ensures that `--interval` cannot be used together with `--month` or `--year`.
+ - If `--interval` is provided, it returns "continuous".
+ - If both `--month` and `--year` are provided, it returns "monthly".
+ - If neither condition is met, it returns an error with an appropriate message.
+ """
+
+ has_interval = 'interval' in args
+ has_month = 'month' in args
+ has_year = 'year' in args
+
+ if has_interval and (has_month or has_year):
+ return "error", "Cannot use --interval with --month/--year together."
+ elif has_interval:
+ return "continuous", None
+ elif has_month and has_year:
+ return "monthly", None
+ else:
+ return "continuous", None