Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## Changelog
All notable changes to this project will be documented in this file. <br/>
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).
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -32,14 +34,16 @@ 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
│ ├── test_display.py
│ ├── test_email_fetcher.py
│ ├── test_email_parser.py
│ ├── test_etd.py
│ └── test_tracker.py
│ └── test_tracker.py.
│ └── test_validators.py.
└── README.md # Project documentation
```
Expand Down
112 changes: 112 additions & 0 deletions tests/test_validators.py
Original file line number Diff line number Diff line change
@@ -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 <class 'list'> 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))


2 changes: 1 addition & 1 deletion tracker/__version__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""
Expense Tracker version
"""
__version__ = "0.1.1-Beta"
__version__ = "0.2.2-Beta"
46 changes: 39 additions & 7 deletions tracker/expense_tracker.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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"))
Expand All @@ -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.")
127 changes: 127 additions & 0 deletions tracker/validators.py
Original file line number Diff line number Diff line change
@@ -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