From 17eb3ce38b6d409512f2548781f9a465c687ca8f Mon Sep 17 00:00:00 2001 From: vubon Date: Mon, 5 May 2025 00:21:18 +0700 Subject: [PATCH 1/3] Retrieve the previous month's data using the CLI. --- tests/test_validators.py | 112 ++++++++++++++++++++++++++++++++ tracker/expense_tracker.py | 46 ++++++++++++-- tracker/validators.py | 127 +++++++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 tests/test_validators.py create mode 100644 tracker/validators.py 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/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 From 9665ab6c0309373ec443a3d81689821f261bc387 Mon Sep 17 00:00:00 2001 From: vubon Date: Mon, 5 May 2025 00:35:02 +0700 Subject: [PATCH 2/3] Update docs and version --- CHANGELOG.md | 6 +++++- README.md | 5 ++++- tracker/__version__.py | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2562f3d..c154430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ ## 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` \ No newline at end of file diff --git a/README.md b/README.md index 1bbc299..3086dd8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ 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. - 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 +33,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 +41,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/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" From aa678dd396f4513584711d4e3e6f36c1eaca87e4 Mon Sep 17 00:00:00 2001 From: vubon Date: Mon, 5 May 2025 00:41:50 +0700 Subject: [PATCH 3/3] Docs update --- CHANGELOG.md | 4 +++- README.md | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c154430..67d1e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,4 +4,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## 0.2.2-Beta - Added a new feature to display old transaction history by CLI - e.g. `etracker --month 10 --year 2023` \ No newline at end of file + 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 3086dd8..95c6f05 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ transactions and summarizing them in daily, monthly, and yearly reports. - **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.