diff --git a/PLAN.md b/PLAN.md index a6714f6..d99ca25 100644 --- a/PLAN.md +++ b/PLAN.md @@ -23,21 +23,21 @@ Each task includes: ### Branch: `feature/issue-1-integrate-codecov` ### Tasks: -- [ ] Improve Codecov configuration in CI workflow - - Verify correct generation of `coverage.xml` - - Add `--cov-report=xml` flag to pytest for XML report generation - - Configure correct parameters for codecov-action - - Add Codecov token (if required) -- [ ] Add Codecov configuration - - Create `codecov.yml` to configure Codecov behavior - - Set minimum coverage threshold - - Configure coverage drop notifications -- [ ] Add coverage check in CI - - Add `--cov-fail-under` for minimum coverage check - - Configure fail when coverage drops below threshold -- [ ] Update documentation - - Add Codecov information to README - - Add link to Codecov dashboard +- [x] Improve Codecov configuration in CI workflow + - [x] Verify correct generation of `coverage.xml` + - [x] Add `--cov-report=xml` flag to pytest for XML report generation + - [x] Configure correct parameters for codecov-action + - [x] Add Codecov token (if required) +- [x] Add Codecov configuration + - [x] Create `codecov.yml` to configure Codecov behavior + - [x] Set minimum coverage threshold + - [x] Configure coverage drop notifications +- [x] Add coverage check in CI + - [x] Add `--cov-fail-under` for minimum coverage check + - [x] Configure fail when coverage drops below threshold +- [x] Update documentation + - [x] Add Codecov information to README + - [x] Add link to Codecov dashboard ### After completion: ```bash @@ -68,25 +68,25 @@ git push origin feature/issue-1-integrate-codecov ### Branch: `feature/issue-2-improve-test-coverage-95` ### Tasks: -- [ ] Analyze current coverage - - Run `pytest --cov=email_processor --cov-report=term-missing` - - Identify modules with low coverage - - Create list of missing tests -- [ ] Add tests for modules with low coverage - - Tests for all public methods and classes - - Tests for edge cases and boundary conditions - - Tests for error handling - - Tests for all code branches (if/else, try/except) -- [ ] Add integration tests - - Tests for full processing cycle - - Tests for module interactions - - Tests for real-world usage scenarios -- [ ] Configure coverage check in CI - - Add `--cov-fail-under=95` to pytest command - - Ensure CI fails when coverage drops below 95% -- [ ] Update testing documentation - - Update `README_TESTS.md` with coverage information - - Add instructions for running tests with coverage +- [x] Analyze current coverage + - [x] Run `pytest --cov=email_processor --cov-report=term-missing` + - [x] Identify modules with low coverage + - [x] Create list of missing tests +- [x] Add tests for modules with low coverage + - [x] Tests for all public methods and classes + - [x] Tests for edge cases and boundary conditions + - [x] Tests for error handling + - [x] Tests for all code branches (if/else, try/except) +- [x] Add integration tests + - [x] Tests for full processing cycle + - [x] Tests for module interactions + - [x] Tests for real-world usage scenarios +- [x] Configure coverage check in CI + - [x] Add `--cov-fail-under=95` to pytest command + - [x] Ensure CI fails when coverage drops below 95% +- [x] Update testing documentation + - [x] Update `README_TESTS.md` with coverage information + - [x] Add instructions for running tests with coverage ### Coverage Goal: ≥95% @@ -119,29 +119,29 @@ git push origin feature/issue-2-improve-test-coverage-95 ### Branch: `feature/issue-3-add-project-documentation` ### Tasks: -- [ ] Add LICENSE file - - Choose license (MIT, Apache 2.0, or other) - - Create `LICENSE` file with full license text - - Update `pyproject.toml` if needed (MIT already specified) -- [ ] Add CODE_OF_CONDUCT.md - - Use Contributor Covenant or create custom one - - Add information on how to report violations - - Add contact information -- [ ] Add SECURITY.md (Security Policy) - - Describe vulnerability reporting process - - Specify supported versions - - Add information on fix process - - Create in `.github/SECURITY.md` or project root -- [ ] Create Issue templates - - Create `.github/ISSUE_TEMPLATE/` directory - - Add template for bug reports (`bug_report.md`) - - Add template for feature requests (`feature_request.md`) - - Add template for questions (`question.md`) - optional - - Configure `config.yml` for template selection -- [ ] Create Pull Request template - - Create `.github/pull_request_template.md` - - Add sections: description, change type, checks, related issues - - Add checklist for pre-PR verification +- [x] Add LICENSE file + - [x] Choose license (MIT, Apache 2.0, or other) + - [x] Create `LICENSE` file with full license text + - [x] Update `pyproject.toml` if needed (MIT already specified) +- [x] Add CODE_OF_CONDUCT.md + - [x] Use Contributor Covenant or create custom one + - [x] Add information on how to report violations + - [x] Add contact information +- [x] Add SECURITY.md (Security Policy) + - [x] Describe vulnerability reporting process + - [x] Specify supported versions + - [x] Add information on fix process + - [x] Create in `.github/SECURITY.md` or project root +- [x] Create Issue templates + - [x] Create `.github/ISSUE_TEMPLATE/` directory + - [x] Add template for bug reports (`bug_report.md`) + - [x] Add template for feature requests (`feature_request.md`) + - [x] Add template for questions (`question.md`) - optional + - [x] Configure `config.yml` for template selection +- [x] Create Pull Request template + - [x] Create `.github/pull_request_template.md` + - [x] Add sections: description, change type, checks, related issues + - [x] Add checklist for pre-PR verification ### After completion: ```bash @@ -227,36 +227,21 @@ git push origin feature/issue-4-add-readme-badges **Description (EN):** Implement standardized exit codes for the CLI to provide clear error reporting and better integration with scripts and automation tools. Define and document exit codes for different error scenarios to improve user experience and enable proper error handling in automated workflows. +**Status:** Implemented (branch `feature/issue-5-standardize-exit-codes`, PR #26). Exit codes are defined in `email_processor.exit_codes.ExitCode` (IntEnum). + ### Ветка: `feature/issue-5-standardize-exit-codes` ### Задачи: -- [ ] Определить константы для exit codes - - Создать модуль `email_processor/constants.py` или добавить в существующий - - Определить константы для всех exit codes: - - `EXIT_SUCCESS = 0` - - `EXIT_PROCESSING_ERROR = 1` - - `EXIT_VALIDATION_FAILED = 2` - - `EXIT_FILE_NOT_FOUND = 3` - - `EXIT_UNSUPPORTED_FORMAT = 4` - - `EXIT_WARNINGS_AS_ERRORS = 5` - - `EXIT_CONFIG_ERROR = 6` -- [ ] Обновить код CLI для использования стандартных exit codes - - Заменить все `return 1` на соответствующие константы - - Определить правильные exit codes для каждого типа ошибки: - - Ошибки обработки (extraction/parsing/mapping/write) → 1 - - Ошибки валидации (в strict mode) → 2 - - Файл не найден → 3 - - Неподдерживаемый формат → 4 - - Warnings as errors (--fail-on-warnings) → 5 - - Ошибки конфигурации → 6 -- [ ] Добавить документацию exit codes в README - - Создать раздел "Exit Codes" в README - - Описать каждый exit code и когда он используется - - Добавить примеры использования в скриптах -- [ ] Добавить тесты для exit codes - - Тесты для каждого типа exit code - - Проверка корректности возвращаемых кодов - - Интеграционные тесты для различных сценариев +- [x] Определить константы для exit codes + - Модуль `email_processor/exit_codes.py` с enum `ExitCode`: + - `SUCCESS = 0`, `PROCESSING_ERROR = 1`, `VALIDATION_FAILED = 2`, `FILE_NOT_FOUND = 3`, `UNSUPPORTED_FORMAT = 4`, `WARNINGS_AS_ERRORS = 5`, `CONFIG_ERROR = 6` +- [x] Обновить код CLI для использования стандартных exit codes + - CLI команды (config, imap, passwords, smtp, status) и `__main__` возвращают `ExitCode` + - Соответствие типов ошибок: обработка → 1, валидация → 2, файл не найден → 3, неподдерживаемый формат → 4, warnings as errors → 5, конфигурация → 6 +- [x] Добавить документацию exit codes в README + - Раздел "Exit Codes" в README, таблица кодов, примеры (bash, Python), типичные сценарии +- [x] Добавить тесты для exit codes + - Unit- и интеграционные тесты обновлены под новые коды; проверка возвращаемых значений ### Стандартные exit codes: - `0`: Success @@ -267,29 +252,11 @@ git push origin feature/issue-4-add-readme-badges - `5`: Warnings as errors (--fail-on-warnings enabled) - `6`: Configuration error -### По окончанию: +### Проверка после внедрения: ```bash -# Тестирование и проверки pytest tests/ -v -# Проверить различные сценарии и их exit codes -python -m email_processor --version # Должен вернуть 0 -python -m email_processor --config nonexistent.yaml # Должен вернуть 6 -# и т.д. - -# Коммит -git add . -git commit -m "feat: standardize CLI exit codes - -- Add exit code constants -- Update CLI to use standardized exit codes -- Add exit codes documentation to README -- Add tests for exit codes - -Fixes #5" - -# Push и создание PR (вручную) -git push origin feature/issue-5-standardize-exit-codes -# Затем создать Pull Request через GitHub UI или CLI +python -m email_processor --version # 0 (SUCCESS) +python -m email_processor run --config nonexistent.yaml # 6 (CONFIG_ERROR) ``` --- @@ -303,24 +270,24 @@ git push origin feature/issue-5-standardize-exit-codes ### Ветка: `feature/issue-6-add-quickstart-section` ### Задачи: -- [ ] Добавить раздел Quickstart в README.md - - Разместить после заголовка и описания, но перед детальными разделами - - Включить минимальные шаги для быстрого старта - - Добавить примеры основных команд +- [x] Добавить раздел Quickstart в README.md + - [x] Разместить после заголовка и описания, но перед детальными разделами + - [x] Включить минимальные шаги для быстрого старта + - [x] Добавить примеры основных команд - [ ] Структура Quickstart раздела: - - Краткое описание (1-2 предложения) - - Установка (pip install или из исходников) - - Создание конфигурации (--create-config) - - Базовый пример использования - - Ссылки на детальную документацию -- [ ] Добавить примеры кода - - Пример минимальной конфигурации - - Пример запуска обработки - - Пример отправки файла через SMTP + - [ ] Краткое описание (1-2 предложения) + - [x] Установка (pip install или из исходников) + - [x] Создание конфигурации (--create-config) + - [x] Базовый пример использования + - [ ] Ссылки на детальную документацию +- [x] Добавить примеры кода + - [x] Пример минимальной конфигурации + - [x] Пример запуска обработки + - [x] Пример отправки файла через SMTP - [ ] Обновить структуру README - - Убедиться, что Quickstart логично вписывается в структуру - - Добавить навигацию или ссылки на детальные разделы - - Проверить читаемость и последовательность + - [ ] Убедиться, что Quickstart логично вписывается в структуру + - [ ] Добавить навигацию или ссылки на детальные разделы + - [ ] Проверить читаемость и последовательность ### По окончанию: ```bash diff --git a/README.md b/README.md index ed22ac2..08ae19f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Email Processor is a reliable, idempotent, and secure tool for automatic email p - stores processed email UIDs in separate files by date - uses keyring for secure password storage - **command structure with subcommands support** +- **standardized exit codes** (`email_processor.exit_codes.ExitCode`) for scripting and automation - **progress bar** for long-running operations - **file extension filtering** (whitelist/blacklist) - **disk space checking** before downloads @@ -274,6 +275,91 @@ python -m email_processor fetch --since 3d --max-emails 20 --log-file logs/run.l --- +## Exit Codes + +The CLI uses standardized exit codes to provide clear error reporting and enable proper error handling in scripts and automation tools. All exit codes are defined in the `ExitCode` enum in `email_processor.exit_codes`. The `main()` entry point and all CLI commands return `ExitCode` values (or exit with them); as an `IntEnum`, they compare equal to their integer values (e.g. `ExitCode.SUCCESS == 0`). + +### Standard Exit Codes + +| Code | Constant | Description | +|------|----------|-------------| +| `0` | `SUCCESS` | Operation completed successfully | +| `1` | `PROCESSING_ERROR` | Errors during extraction, parsing, mapping, or write operations | +| `2` | `VALIDATION_FAILED` | Input validation errors (e.g., invalid arguments, email format) | +| `3` | `FILE_NOT_FOUND` | Requested file or directory does not exist | +| `4` | `UNSUPPORTED_FORMAT` | Cannot detect or process the requested format (e.g., authentication/keyring errors) | +| `5` | `WARNINGS_AS_ERRORS` | Warnings were treated as errors (when `--fail-on-warnings` is enabled) | +| `6` | `CONFIG_ERROR` | Errors loading or validating configuration file | + +### Usage in Scripts + +You can use exit codes in shell scripts to handle different error scenarios: + +```bash +#!/bin/bash + +# Run email processor +python -m email_processor run + +# Check exit code +case $? in + 0) + echo "Success: Emails processed successfully" + ;; + 1) + echo "Error: Processing failed" + exit 1 + ;; + 2) + echo "Error: Invalid arguments or validation failed" + exit 1 + ;; + 3) + echo "Error: File not found" + exit 1 + ;; + 6) + echo "Error: Configuration file error" + exit 1 + ;; + *) + echo "Error: Unknown error" + exit 1 + ;; +esac +``` + +### Python Script Example + +```python +import subprocess +from email_processor.exit_codes import ExitCode + +result = subprocess.run( + ["python", "-m", "email_processor", "run"], + capture_output=True +) + +if result.returncode == ExitCode.SUCCESS: + print("Processing completed successfully") +elif result.returncode == ExitCode.CONFIG_ERROR: + print("Configuration error - check config.yaml") +elif result.returncode == ExitCode.PROCESSING_ERROR: + print("Processing error occurred") +else: + print(f"Unexpected exit code: {result.returncode}") +``` + +### Common Exit Code Scenarios + +- **`0` (SUCCESS)**: Command executed successfully +- **`1` (PROCESSING_ERROR)**: IMAP/SMTP processing failed, send/archive error, or write error +- **`2` (VALIDATION_FAILED)**: Invalid email address, missing required arguments, or invalid command +- **`3` (FILE_NOT_FOUND)**: Configuration file not found, password file not found, or target file/directory missing +- **`4` (UNSUPPORTED_FORMAT)**: Authentication/keyring error or unsupported format +- **`6` (CONFIG_ERROR)**: Configuration file syntax error, validation failure, or missing required settings + +--- ## 🔒 Password Encryption diff --git a/email_processor/__main__.py b/email_processor/__main__.py index e084a21..7cd98f1 100644 --- a/email_processor/__main__.py +++ b/email_processor/__main__.py @@ -8,16 +8,9 @@ from email_processor.cli import CLIUI from email_processor.cli.args import parse_arguments from email_processor.cli.commands import config, imap, passwords, smtp, status +from email_processor.exit_codes import ExitCode from email_processor.logging.setup import get_logger, setup_logging -# Exit codes according to spec -EXIT_SUCCESS = 0 -EXIT_ERROR = 1 -EXIT_INVALID_ARGS = 2 -EXIT_CONFIG_ERROR = 3 -EXIT_AUTH_ERROR = 4 -EXIT_NETWORK_ERROR = 5 - def _validate_email(email_str: str) -> bool: """Validate email address format. @@ -49,20 +42,20 @@ def _load_config(config_path: str, ui: CLIUI) -> tuple[dict, int]: """ try: cfg = ConfigLoader.load(config_path, ui=ui) - return cfg, EXIT_SUCCESS + return cfg, ExitCode.SUCCESS except FileNotFoundError as e: ui.error(str(e)) if ui.has_rich: ui.print(f"Please create [cyan]{config_path}[/cyan] based on config.yaml.example") else: ui.info(f"Please create {config_path} based on config.yaml.example") - return {}, EXIT_CONFIG_ERROR + return {}, ExitCode.CONFIG_ERROR except ValueError as e: ui.error(f"Configuration error: {e}") - return {}, EXIT_CONFIG_ERROR + return {}, ExitCode.CONFIG_ERROR except Exception as e: ui.error(f"Unexpected error loading configuration: {e}") - return {}, EXIT_CONFIG_ERROR + return {}, ExitCode.CONFIG_ERROR def _setup_logging_from_args(cfg: dict, args) -> None: @@ -147,7 +140,7 @@ def main() -> int: # Command: config validate if args.command == "config" and args.config_command == "validate": cfg, status_code = _load_config(args.config, ui) - if status_code != EXIT_SUCCESS: + if status_code != ExitCode.SUCCESS: return status_code return config.validate_config_file(args.config, ui) @@ -158,7 +151,7 @@ def main() -> int: # Commands that require config loading config_path = args.config cfg, status_code = _load_config(config_path, ui) - if status_code != EXIT_SUCCESS: + if status_code != ExitCode.SUCCESS: return status_code # Setup logging @@ -168,7 +161,7 @@ def main() -> int: if args.command == "password" and args.password_command == "set": if not args.user: ui.error("--user is required") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED return passwords.set_password( args.user, args.password_file if hasattr(args, "password_file") else None, @@ -181,28 +174,28 @@ def main() -> int: if args.command == "password" and args.password_command == "clear": if not args.user: ui.error("--user is required") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED return passwords.clear_passwords(args.user, ui) # Command: send file if args.command == "send" and args.send_command == "file": if not hasattr(args, "path") or not args.path: ui.error("File path is required") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED if not hasattr(args, "to") or not args.to: ui.error("--to is required") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED # Validate email addresses if not _validate_email(args.to): ui.error(f"Invalid email address: {args.to}") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED if hasattr(args, "cc") and args.cc and not _validate_email(args.cc): ui.error(f"Invalid CC email address: {args.cc}") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED if hasattr(args, "bcc") and args.bcc and not _validate_email(args.bcc): ui.error(f"Invalid BCC email address: {args.bcc}") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED return smtp.send_file( cfg, @@ -218,21 +211,21 @@ def main() -> int: if args.command == "send" and args.send_command == "folder": if not hasattr(args, "dir") or not args.dir: ui.error("Directory path is required") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED if not hasattr(args, "to") or not args.to: ui.error("--to is required") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED # Validate email addresses if not _validate_email(args.to): ui.error(f"Invalid email address: {args.to}") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED if hasattr(args, "cc") and args.cc and not _validate_email(args.cc): ui.error(f"Invalid CC email address: {args.cc}") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED if hasattr(args, "bcc") and args.bcc and not _validate_email(args.bcc): ui.error(f"Invalid BCC email address: {args.bcc}") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED return smtp.send_folder( cfg, @@ -300,7 +293,7 @@ def main() -> int: # Unknown command ui.error(f"Unknown command: {args.command}") - return EXIT_INVALID_ARGS + return ExitCode.VALIDATION_FAILED if __name__ == "__main__": diff --git a/email_processor/cli/commands/config.py b/email_processor/cli/commands/config.py index 645044e..bf46e32 100644 --- a/email_processor/cli/commands/config.py +++ b/email_processor/cli/commands/config.py @@ -5,6 +5,7 @@ from email_processor.cli.ui import CLIUI from email_processor.config.loader import ConfigLoader, validate_config +from email_processor.exit_codes import ExitCode CONFIG_EXAMPLE = "config.yaml.example" @@ -25,13 +26,13 @@ def create_default_config(config_path: str, ui: CLIUI) -> int: if not example_path.exists(): ui.error(f"Template file {CONFIG_EXAMPLE} not found") ui.info(f"Expected location: {example_path.absolute()}") - return 1 + return ExitCode.FILE_NOT_FOUND if target_path.exists(): response = ui.input(f"Configuration file {config_path} already exists. Overwrite? [y/N]: ") if response.lower() != "y": ui.warn("Cancelled.") - return 0 + return ExitCode.SUCCESS try: # Create parent directories if needed @@ -43,10 +44,10 @@ def create_default_config(config_path: str, ui: CLIUI) -> int: ui.print(f"Please edit [cyan]{config_path}[/cyan] with your IMAP settings.") else: ui.info(f"Please edit {config_path} with your IMAP settings.") - return 0 + return ExitCode.SUCCESS except OSError as e: ui.error(f"Error creating configuration file: {e}") - return 1 + return ExitCode.PROCESSING_ERROR def validate_config_file(config_path: str, ui: CLIUI) -> int: @@ -63,13 +64,13 @@ def validate_config_file(config_path: str, ui: CLIUI) -> int: cfg = ConfigLoader.load(config_path, ui=ui) validate_config(cfg, ui=ui) ui.success(f"Configuration file is valid: {config_path}") - return 0 + return ExitCode.SUCCESS except FileNotFoundError as e: ui.error(f"Configuration file not found: {e}") - return 1 + return ExitCode.FILE_NOT_FOUND except ValueError as e: ui.error(f"Configuration validation failed: {e}") - return 3 + return ExitCode.CONFIG_ERROR except Exception as e: ui.error(f"Unexpected error validating configuration: {e}") - return 1 + return ExitCode.PROCESSING_ERROR diff --git a/email_processor/cli/commands/imap.py b/email_processor/cli/commands/imap.py index 7f68358..9be5819 100644 --- a/email_processor/cli/commands/imap.py +++ b/email_processor/cli/commands/imap.py @@ -5,6 +5,7 @@ from email_processor import EmailProcessor from email_processor.cli.ui import CLIUI +from email_processor.exit_codes import ExitCode from email_processor.imap.fetcher import ProcessingResult @@ -29,13 +30,13 @@ def run_processor( # Display results _display_results(result, ui) - return 0 + return ExitCode.SUCCESS except KeyboardInterrupt: logging.info("Interrupted by user") - return 0 + return ExitCode.SUCCESS except Exception: logging.exception("Fatal error during email processing") - return 1 + return ExitCode.PROCESSING_ERROR def _display_results(result: ProcessingResult, ui: CLIUI) -> None: diff --git a/email_processor/cli/commands/passwords.py b/email_processor/cli/commands/passwords.py index bb2e9f4..abd4b31 100644 --- a/email_processor/cli/commands/passwords.py +++ b/email_processor/cli/commands/passwords.py @@ -10,6 +10,7 @@ from email_processor import KEYRING_SERVICE_NAME from email_processor import clear_passwords as clear_passwords_func from email_processor.cli.ui import CLIUI +from email_processor.exit_codes import ExitCode from email_processor.logging.setup import get_logger from email_processor.security.encryption import encrypt_password @@ -77,10 +78,10 @@ def clear_passwords(user: str, ui: CLIUI) -> int: try: clear_passwords_func(KEYRING_SERVICE_NAME, user) ui.success(f"Password cleared for {user}") - return 0 + return ExitCode.SUCCESS except Exception as e: ui.error(f"Failed to clear password: {e}") - return 1 + return ExitCode.PROCESSING_ERROR def set_password( @@ -106,14 +107,14 @@ def set_password( if password_file: password = _read_password_from_file(password_file, ui) if password is None: - return 1 + return ExitCode.FILE_NOT_FOUND password_path = Path(password_file) else: # Prompt for password password = ui.input("Enter password: ") if not password: ui.error("Password cannot be empty") - return 1 + return ExitCode.VALIDATION_FAILED password_path = None # Log password length for debugging (without showing actual password) @@ -149,7 +150,7 @@ def set_password( ui.success(f"Password saved for {user}") except Exception as e2: ui.error(f"Failed to save password: {e2}") - return 4 # Authentication error + return ExitCode.UNSUPPORTED_FORMAT # Authentication/keyring error # Remove password file if requested if delete_after_read and password_path: @@ -160,4 +161,4 @@ def set_password( ui.warn(f"Failed to remove password file: {e}") # Don't fail the command if file removal fails - return 0 + return ExitCode.SUCCESS diff --git a/email_processor/cli/commands/smtp.py b/email_processor/cli/commands/smtp.py index 8ce6a20..7a3a030 100644 --- a/email_processor/cli/commands/smtp.py +++ b/email_processor/cli/commands/smtp.py @@ -5,6 +5,7 @@ from typing import Optional from email_processor.cli.ui import CLIUI +from email_processor.exit_codes import ExitCode from email_processor.imap.auth import get_imap_password from email_processor.smtp.config import SMTPConfig from email_processor.smtp.sender import EmailSender @@ -39,21 +40,21 @@ def send_file( cfg, to_address, config_path, ui ) if smtp_cfg is None: - return 1 + return ExitCode.CONFIG_ERROR file_path_obj = Path(file_path) if not file_path_obj.exists(): ui.error(f"File not found: {file_path_obj}") - return 1 + return ExitCode.FILE_NOT_FOUND if not file_path_obj.is_file(): ui.error(f"Not a file: {file_path_obj}") - return 1 + return ExitCode.VALIDATION_FAILED # Check if already sent if not dry_run and storage.is_sent(file_path_obj, day_str): ui.warn(f"File already sent: {file_path_obj.name}") - return 0 + return ExitCode.SUCCESS # Send file success = sender.send_file(file_path_obj, final_recipient, subject, dry_run=dry_run) @@ -68,9 +69,9 @@ def send_file( ui.info(f"DRY-RUN: Would send file: {file_path_obj.name}") else: ui.error(f"Failed to send file: {file_path_obj.name}") - return 1 + return ExitCode.PROCESSING_ERROR - return 0 + return ExitCode.SUCCESS def send_folder( @@ -99,23 +100,23 @@ def send_folder( smtp_cfg = cfg.get("smtp") if not smtp_cfg: ui.error("'smtp' section is missing in config.yaml") - return 1 + return ExitCode.CONFIG_ERROR # Initialize SMTP components smtp_cfg_obj, sender, storage, day_str, final_recipient = _init_smtp_components( cfg, to_address, config_path, ui ) if smtp_cfg_obj is None: - return 1 + return ExitCode.CONFIG_ERROR folder_path_obj = Path(folder_path) if not folder_path_obj.exists(): ui.error(f"Folder not found: {folder_path_obj}") - return 1 + return ExitCode.FILE_NOT_FOUND if not folder_path_obj.is_dir(): ui.error(f"Not a folder: {folder_path_obj}") - return 1 + return ExitCode.VALIDATION_FAILED # Find new files all_files = [f for f in folder_path_obj.iterdir() if f.is_file()] @@ -135,7 +136,7 @@ def send_folder( ) else: ui.info(f"No new files to send (skipped {skipped_count} already sent)") - return 0 + return ExitCode.SUCCESS # Send files sent_count = 0 @@ -164,7 +165,7 @@ def send_folder( if failed_count > 0: ui.info(f"Failed: {failed_count} files") - return 0 if failed_count == 0 else 1 + return ExitCode.SUCCESS if failed_count == 0 else ExitCode.PROCESSING_ERROR def _init_smtp_components( diff --git a/email_processor/cli/commands/status.py b/email_processor/cli/commands/status.py index b42dfb2..2fd9683 100644 --- a/email_processor/cli/commands/status.py +++ b/email_processor/cli/commands/status.py @@ -7,6 +7,7 @@ from email_processor import KEYRING_SERVICE_NAME, __version__ from email_processor.cli.ui import CLIUI from email_processor.config.loader import ConfigLoader +from email_processor.exit_codes import ExitCode def show_status(config_path: str, ui: CLIUI) -> int: @@ -92,4 +93,4 @@ def show_status(config_path: str, ui: CLIUI) -> int: except Exception as e: ui.warn(f"Keyring: Not available ({e})") - return 0 + return ExitCode.SUCCESS diff --git a/email_processor/exit_codes.py b/email_processor/exit_codes.py new file mode 100644 index 0000000..dccb2f4 --- /dev/null +++ b/email_processor/exit_codes.py @@ -0,0 +1,43 @@ +"""Exit codes for CLI commands. + +This module defines standardized exit codes used throughout the application +to provide clear error reporting and better integration with scripts and automation tools. + +See also: README § Exit Codes for the full table, usage examples (bash, Python), +and common scenarios. +""" + +from enum import IntEnum + + +class ExitCode(IntEnum): + """Standardized exit codes for CLI commands. + + These codes follow common Unix conventions and provide clear error reporting + for different types of failures, enabling proper error handling in automated workflows. + """ + + SUCCESS = 0 + """Success - operation completed successfully.""" + + PROCESSING_ERROR = 1 + """Processing error - errors during extraction, parsing, mapping, or write operations.""" + + VALIDATION_FAILED = 2 + """Validation failed - input validation errors (e.g., invalid arguments, strict mode failures).""" + + FILE_NOT_FOUND = 3 + """File not found - requested file or directory does not exist.""" + + UNSUPPORTED_FORMAT = 4 + """Unsupported format - cannot detect or process the requested format.""" + + WARNINGS_AS_ERRORS = 5 + """Warnings as errors - warnings were treated as errors (e.g., --fail-on-warnings enabled).""" + + CONFIG_ERROR = 6 + """Configuration error - errors loading or validating configuration file.""" + + def __int__(self) -> int: + """Return integer value of the exit code.""" + return self.value diff --git a/email_processor_run.bat b/email_processor_run.bat index 8459d18..24391bd 100644 --- a/email_processor_run.bat +++ b/email_processor_run.bat @@ -1,5 +1,7 @@ @echo off +REM Run email processor (full pipeline: fetch + send). +REM Ensure UTF-8 for log output. chcp 65001 >nul set PYTHONUTF8=1 -py -m email_processor +py -m email_processor run pause diff --git a/install_context_menu.py b/install_context_menu.py index 0e21471..f11ed49 100644 --- a/install_context_menu.py +++ b/install_context_menu.py @@ -1,39 +1,95 @@ -"""Script to generate Windows context menu registry file.""" +"""Script to generate Windows context menu registry file for 'Send via email'.""" +import argparse import sys from pathlib import Path +from typing import Optional -# Get paths -script_dir = Path(__file__).parent.absolute() -python_exe = sys.executable -main_script = script_dir / "email_processor" / "__main__.py" +# Paths +SCRIPT_DIR = Path(__file__).resolve().parent +CONFIG_FILE = "config.yaml" +DEFAULT_RECIPIENT_PLACEHOLDER = "REPLACE_WITH_YOUR_EMAIL" -def generate_reg_file(output_path: Path) -> None: - """Generate .reg file for Windows context menu.""" - # Escape backslashes for registry - python_exe_escaped = str(python_exe).replace("\\", "\\\\") - main_script_escaped = str(main_script).replace("\\", "\\\\") +def generate_reg_file(output_path: Path, to_email: str, config_path: Optional[Path]) -> None: + """Generate .reg file for Windows context menu. + + Invokes: python -m email_processor send file "%1" --to [--config ]. + """ + python_exe = sys.executable + parts = [ + f'"{python_exe}"', + "-m", + "email_processor", + "send", + "file", + '"%1"', + "--to", + f'"{to_email}"', + ] + if config_path and config_path.exists(): + parts.extend(["--config", f'"{config_path}"']) + cmd = " ".join(parts) + # Registry: escape backslashes then quotes for the command string value + cmd_reg = cmd.replace("\\", "\\\\").replace('"', '\\"') + python_escaped = str(python_exe).replace("\\", "\\\\") reg_content = f"""Windows Registry Editor Version 5.00 [HKEY_CURRENT_USER\\Software\\Classes\\*\\shell\\SendViaEmail] -@="Отправить по email" -"Icon"="{python_exe_escaped}" +@="Send via email" +"Icon"="{python_escaped}" [HKEY_CURRENT_USER\\Software\\Classes\\*\\shell\\SendViaEmail\\command] -@="{python_exe_escaped} \\"{main_script_escaped}\\" --send-file \\"%1\\"" +@="{cmd_reg}" """ output_path.write_text(reg_content, encoding="utf-16") print(f"Registry file generated: {output_path}") print("\nTo install:") - print(f" 1. Double-click {output_path.name}") - print(" 2. Confirm the registry merge") + print( + f" 1. Edit {output_path.name} and set the correct recipient (--to) if you used a placeholder." + ) + print(f" 2. Double-click {output_path.name}") + print(" 3. Confirm the registry merge.") print("\nTo uninstall, delete the registry keys:") print(" HKEY_CURRENT_USER\\Software\\Classes\\*\\shell\\SendViaEmail") +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate Windows context menu .reg for 'Send via email' (send file)." + ) + parser.add_argument( + "--to", + type=str, + default=DEFAULT_RECIPIENT_PLACEHOLDER, + help=f"Recipient email for 'send file'. Default: {DEFAULT_RECIPIENT_PLACEHOLDER} (edit .reg before install).", + ) + parser.add_argument( + "--config", + type=Path, + default=None, + help="Path to config.yaml. Default: project config.yaml next to this script.", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=None, + help="Output .reg path. Default: install_context_menu.reg in script directory.", + ) + args = parser.parse_args() + + config_path = args.config if args.config is not None else (SCRIPT_DIR / CONFIG_FILE) + output_path = args.output or (SCRIPT_DIR / "install_context_menu.reg") + generate_reg_file(output_path, args.to, config_path) + + if args.to == DEFAULT_RECIPIENT_PLACEHOLDER: + print( + f"\nNote: Recipient is set to '{DEFAULT_RECIPIENT_PLACEHOLDER}'. Edit the .reg file and replace it before installing." + ) + + if __name__ == "__main__": - output_file = script_dir / "install_context_menu.reg" - generate_reg_file(output_file) + main() diff --git a/run_tests.bat b/run_tests.bat index 1c30fe8..93bb97e 100644 --- a/run_tests.bat +++ b/run_tests.bat @@ -1,6 +1,8 @@ @echo off +REM Run pytest over the test suite (quick run, no coverage). +REM For coverage, use: py -m tests.run_all_tests or pytest with --cov. chcp 65001 >nul set PYTHONUTF8=1 echo Running tests... -pytest -v +py -m pytest tests/ -v pause diff --git a/test_email_processor.bat b/test_email_processor.bat index 91eb8d6..d6e3a27 100644 --- a/test_email_processor.bat +++ b/test_email_processor.bat @@ -1,4 +1,6 @@ @echo off +REM Manual integration test: run pipeline, then send folder. +REM Requires config.yaml and EMAIL_PROCESSOR_TEST_RECIPIENT for send step. chcp 65001 >nul set PYTHONUTF8=1 setlocal @@ -8,68 +10,72 @@ echo Email Processor Testing Script echo ======================================== echo. -REM Check if virtual environment exists and use it +REM Prefer venv Python if present if exist ".venv\Scripts\python.exe" ( - set PYTHON_CMD=.venv\Scripts\python.exe + set "PYTHON_CMD=.venv\Scripts\python.exe" ) else ( - set PYTHON_CMD=py + set "PYTHON_CMD=py" ) -REM Step 1: Test email receiving (default mode) -echo [1/3] Testing email receiving (default mode)... +REM Step 1: Run full pipeline (fetch + send) +echo [1/3] Running full pipeline (fetch + send)... echo ---------------------------------------- -%PYTHON_CMD% -m email_processor +"%PYTHON_CMD%" -m email_processor run if errorlevel 1 ( - echo ERROR: Email receiving failed! + echo ERROR: Pipeline failed! pause exit /b 1 ) echo. -echo Email receiving completed successfully! +echo Pipeline completed successfully. echo. -REM Step 2: Create test file for sending -echo [2/3] Creating test file for sending... +REM Step 2: Create test file for send-folder step +echo [2/3] Creating test file for send-folder... echo ---------------------------------------- -REM Ensure send_folder exists if not exist "send_folder" ( - echo Creating send_folder directory... + echo Creating send_folder... mkdir send_folder ) -REM Create test file with timestamp -for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value') do set datetime=%%I -set TEST_FILE=send_folder\test_%datetime:~0,8%_%datetime:~8,6%.txt +for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value 2^>nul') do set "datetime=%%I" +set "TEST_FILE=send_folder\test_%datetime:~0,8%_%datetime:~8,6%.txt" echo Test file content > "%TEST_FILE%" echo Created at: %date% %time% >> "%TEST_FILE%" -echo This is a test file for email sending functionality. >> "%TEST_FILE%" +echo This is a test file for email sending. >> "%TEST_FILE%" echo File path: %TEST_FILE% >> "%TEST_FILE%" echo. if not exist "%TEST_FILE%" ( - echo ERROR: Failed to create test file! + echo ERROR: Failed to create test file. pause exit /b 1 ) echo Test file created: %TEST_FILE% echo. -REM Step 3: Test email sending -echo [3/3] Testing email sending... +REM Step 3: Send folder (requires --to) +echo [3/3] Sending folder via SMTP... echo ---------------------------------------- -%PYTHON_CMD% -m email_processor --send-folder send_folder +if not defined EMAIL_PROCESSOR_TEST_RECIPIENT ( + echo ERROR: Set EMAIL_PROCESSOR_TEST_RECIPIENT before running send step. + echo Example: set EMAIL_PROCESSOR_TEST_RECIPIENT=you@example.com + pause + exit /b 1 +) +"%PYTHON_CMD%" -m email_processor send folder send_folder --to "%EMAIL_PROCESSOR_TEST_RECIPIENT%" if errorlevel 1 ( - echo ERROR: Email sending failed! + echo ERROR: Send folder failed! pause exit /b 1 ) echo. -echo Email sending completed successfully! +echo Send folder completed successfully. echo. echo ======================================== -echo All tests completed successfully! +echo All steps completed successfully. echo ======================================== pause diff --git a/tests/run_all_tests.py b/tests/run_all_tests.py index b3cee77..60d68b2 100644 --- a/tests/run_all_tests.py +++ b/tests/run_all_tests.py @@ -14,7 +14,7 @@ "--cov=email_processor", "--cov-report=term-missing", "--cov-report=html", - "--cov-fail-under=90", + "--cov-fail-under=95", ] result = subprocess.run(cmd, check=False) diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index f58cb59..91b9095 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -11,6 +11,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from email_processor.__main__ import main +from email_processor.exit_codes import ExitCode class TestCLIIntegration(unittest.TestCase): @@ -251,7 +252,7 @@ def test_invalid_command(self): # argparse will raise SystemExit for invalid command with self.assertRaises(SystemExit) as cm: main() - self.assertEqual(cm.exception.code, 2) # EXIT_INVALID_ARGS + self.assertEqual(cm.exception.code, ExitCode.VALIDATION_FAILED) @patch("email_processor.__main__.ConfigLoader") def test_config_file_not_found(self, mock_config_loader_class): @@ -260,7 +261,7 @@ def test_config_file_not_found(self, mock_config_loader_class): with patch("sys.argv", ["email_processor", "run"]): result = main() - self.assertEqual(result, 3) # EXIT_CONFIG_ERROR + self.assertEqual(result, ExitCode.CONFIG_ERROR) @patch("email_processor.__main__.ConfigLoader") def test_config_validation_error(self, mock_config_loader_class): @@ -269,7 +270,7 @@ def test_config_validation_error(self, mock_config_loader_class): with patch("sys.argv", ["email_processor", "run"]): result = main() - self.assertEqual(result, 3) # EXIT_CONFIG_ERROR + self.assertEqual(result, ExitCode.CONFIG_ERROR) if __name__ == "__main__": diff --git a/tests/test_full_cycle_integration.py b/tests/test_full_cycle_integration.py index 49cf96b..2b10e7e 100644 --- a/tests/test_full_cycle_integration.py +++ b/tests/test_full_cycle_integration.py @@ -218,37 +218,43 @@ def test_full_cycle_send_folder(self, mock_smtp_connect, mock_imap_class, mock_g pdf_files = list(invoice_folder.glob("*.pdf")) self.assertGreater(len(pdf_files), 0, "PDF files should be downloaded") - # Create proper SMTP config dict + # Create proper SMTP config dict (use isolated sent_files_dir to avoid + # sharing state with other tests—same file hash could be marked sent) smtp_config_dict = { "smtp": { "server": "smtp.example.com", "port": 587, "user": "sender@example.com", "password": "password", - "from": "sender@example.com", + "from_address": "sender@example.com", + "sent_files_dir": str(self.sent_files_dir), } } mock_ui = MagicMock() mock_ui.has_rich = False - result = send_folder( - smtp_config_dict, - str(invoice_folder), - "recipient@example.com", - subject=None, - dry_run=False, - config_path=None, - ui=mock_ui, - ) - # Result can be 0 (success) or 1 (partial failure), but should not be > 1 - self.assertLessEqual( - result, 1, f"Send folder should succeed or have partial failure, got {result}" + # Mock get_imap_password to avoid password prompt + with patch("email_processor.cli.commands.smtp.get_imap_password", return_value="password"): + result = send_folder( + smtp_config_dict, + str(invoice_folder), + "recipient@example.com", + subject=None, + dry_run=False, + config_path="config.yaml", + ui=mock_ui, + ) + from email_processor.exit_codes import ExitCode + + # Result can be SUCCESS (0) or PROCESSING_ERROR (1) for partial failure, but should not be CONFIG_ERROR (6) + self.assertIn( + result, + (ExitCode.SUCCESS, ExitCode.PROCESSING_ERROR), + f"Send folder should succeed or have partial failure, got {result}", ) # Step 3: Verify SMTP was called (if files were sent) - # SMTP may not be called if all files were already sent or skipped - # But if result is 0, at least some files should have been sent - if result == 0: - # If successful, SMTP should have been called + # We use isolated sent_files_dir, so no files are pre-marked sent; on SUCCESS we must have sent. + if result == ExitCode.SUCCESS: self.assertTrue( mock_smtp_connect.called, "SMTP should be connected when files are sent" ) diff --git a/tests/unit/cli/commands/test_config.py b/tests/unit/cli/commands/test_config.py index 953987d..363dc16 100644 --- a/tests/unit/cli/commands/test_config.py +++ b/tests/unit/cli/commands/test_config.py @@ -6,6 +6,7 @@ from email_processor.cli import CLIUI from email_processor.cli.commands.config import create_default_config, validate_config_file +from email_processor.exit_codes import ExitCode class TestCreateConfigErrors(unittest.TestCase): @@ -30,7 +31,7 @@ def test_create_config_os_error(self, mock_copy, mock_path_class): ui = CLIUI() with patch.object(ui, "error") as mock_error: result = create_default_config("config.yaml", ui) - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.PROCESSING_ERROR) mock_error.assert_called_once() @patch("email_processor.cli.commands.config.Path") @@ -52,7 +53,7 @@ def test_create_config_os_error_with_rich(self, mock_copy, mock_path_class): ui = CLIUI() with patch.object(ui, "error") as mock_error: result = create_default_config("config.yaml", ui) - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.PROCESSING_ERROR) mock_error.assert_called_once() @@ -69,7 +70,7 @@ def test_validate_config_file_success(self, mock_validate, mock_load): ui = CLIUI() with patch.object(ui, "success") as mock_success: result = validate_config_file("config.yaml", ui) - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_load.assert_called_once_with("config.yaml", ui=ui) mock_validate.assert_called_once_with({"imap": {"server": "imap.example.com"}}, ui=ui) mock_success.assert_called_once() @@ -82,7 +83,7 @@ def test_validate_config_file_not_found(self, mock_load): ui = CLIUI() with patch.object(ui, "error") as mock_error: result = validate_config_file("nonexistent.yaml", ui) - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) mock_error.assert_called_once() @patch("email_processor.cli.commands.config.ConfigLoader.load") @@ -95,7 +96,7 @@ def test_validate_config_file_validation_error(self, mock_validate, mock_load): ui = CLIUI() with patch.object(ui, "error") as mock_error: result = validate_config_file("config.yaml", ui) - self.assertEqual(result, 3) # EXIT_CONFIG_ERROR + self.assertEqual(result, ExitCode.CONFIG_ERROR) mock_load.assert_called_once_with("config.yaml", ui=ui) mock_validate.assert_called_once_with({"imap": {}}, ui=ui) mock_error.assert_called_once() @@ -108,7 +109,7 @@ def test_validate_config_file_unexpected_error(self, mock_load): ui = CLIUI() with patch.object(ui, "error") as mock_error: result = validate_config_file("config.yaml", ui) - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.PROCESSING_ERROR) mock_error.assert_called_once() @@ -134,6 +135,6 @@ def test_create_config_with_rich_console(self, mock_console_class, mock_copy, mo ui = CLIUI() with patch.object(ui, "print") as mock_print: result = create_default_config("config.yaml", ui) - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) # Check that print was called with rich formatting mock_print.assert_called_once() diff --git a/tests/unit/cli/commands/test_passwords.py b/tests/unit/cli/commands/test_passwords.py index a747eb2..bcbd12c 100644 --- a/tests/unit/cli/commands/test_passwords.py +++ b/tests/unit/cli/commands/test_passwords.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch from email_processor.__main__ import main +from email_processor.exit_codes import ExitCode from email_processor.security.encryption import is_encrypted @@ -152,7 +153,7 @@ def test_set_password_file_not_exists(self, mock_load_config): ], ): result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) @patch("email_processor.config.loader.ConfigLoader.load") def test_set_password_file_empty(self, mock_load_config): @@ -181,7 +182,7 @@ def test_set_password_file_empty(self, mock_load_config): ], ): result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) finally: Path(password_file).unlink(missing_ok=True) @@ -203,7 +204,7 @@ def test_set_password_without_password_file(self, mock_ui_class, mock_load_confi ): with patch("email_processor.__main__.CLIUI", mock_ui_class): result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.config.loader.ConfigLoader.load") @@ -657,7 +658,7 @@ def test_set_password_file_permission_error(self, mock_load_config): # Remove old mock_print patches - use mock_ui instead with patch("builtins.open", side_effect=PermissionError("Permission denied")): result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) mock_ui.error.assert_called() finally: # Restore permissions for cleanup @@ -699,7 +700,7 @@ def test_set_password_file_read_error(self, mock_load_config): # Remove old mock_print patches - use mock_ui instead with patch("builtins.open", side_effect=OSError("Read error")): result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) mock_ui.error.assert_called() finally: Path(password_file).unlink(missing_ok=True) @@ -791,7 +792,11 @@ def test_set_password_save_error_after_encryption_fail( side_effect=Exception("Encryption error"), ): result = main() - self.assertEqual(result, 4) # EXIT_AUTH_ERROR when keyring save fails + from email_processor.exit_codes import ExitCode + + self.assertEqual( + result, ExitCode.UNSUPPORTED_FORMAT + ) # Authentication/keyring error mock_ui.error.assert_called() finally: Path(password_file).unlink(missing_ok=True) diff --git a/tests/unit/cli/commands/test_smtp.py b/tests/unit/cli/commands/test_smtp.py index c9de5a0..b427778 100644 --- a/tests/unit/cli/commands/test_smtp.py +++ b/tests/unit/cli/commands/test_smtp.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch from email_processor.__main__ import main +from email_processor.exit_codes import ExitCode from email_processor.imap.fetcher import ProcessingMetrics, ProcessingResult @@ -117,7 +118,7 @@ def test_send_file_not_found( mock_ui_class.return_value = mock_ui with patch("pathlib.Path.exists", return_value=False): result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) # Check that error message was printed mock_ui.error.assert_called() @@ -280,7 +281,16 @@ def test_send_file_failed( mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -513,7 +523,16 @@ def test_send_file_not_a_file( mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -555,7 +574,16 @@ def test_send_folder_not_a_folder( mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -596,7 +624,16 @@ def test_send_folder_not_specified_no_config( mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -664,7 +701,9 @@ def is_sent_side_effect(file_path, day_str): mock_smtp = MagicMock() mock_smtp_connect_patch.return_value = mock_smtp result = main() - self.assertEqual(result, 1) # Failed because one file failed + self.assertEqual( + result, ExitCode.PROCESSING_ERROR + ) # Failed because one file failed # Check that messages were printed via UI (info or print) # When has_rich is False, it uses info() instead of print() # Note: info() is called multiple times, so check if any call was made @@ -816,7 +855,16 @@ def test_send_file_missing_smtp_config(self, mock_load_config): mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -840,7 +888,9 @@ def test_send_file_missing_recipient(self, mock_get_password, mock_load_config): # argparse will raise SystemExit: 2 when required --to is missing with self.assertRaises(SystemExit) as cm: main() - self.assertEqual(cm.exception.code, 2) # EXIT_INVALID_ARGS + from email_processor.exit_codes import ExitCode + + self.assertEqual(cm.exception.code, ExitCode.VALIDATION_FAILED) @patch("email_processor.__main__.ConfigLoader") @patch("email_processor.cli.commands.smtp.get_imap_password") @@ -876,7 +926,7 @@ def test_send_file_password_error( side_effect=Exception("Password error"), ): result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.CONFIG_ERROR) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -1042,7 +1092,7 @@ def test_send_folder_not_found( mock_ui_class.return_value = mock_ui with patch("pathlib.Path.exists", return_value=False): result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) # Check that error message was printed mock_ui.error.assert_called() @@ -1080,7 +1130,7 @@ def test_send_folder_not_specified( mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) # Check that error message was printed mock_ui.error.assert_called() @@ -1141,7 +1191,7 @@ def test_send_file_missing_smtp_server(self, mock_get_password, mock_load_config mock_storage.is_sent.return_value = False mock_storage_class.return_value = mock_storage result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.CONFIG_ERROR) mock_ui.error.assert_called() finally: Path(test_file).unlink(missing_ok=True) @@ -1182,7 +1232,7 @@ def test_send_file_missing_smtp_user(self, mock_load_config): mock_storage.is_sent.return_value = False mock_storage_class.return_value = mock_storage result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.CONFIG_ERROR) mock_ui.error.assert_called() finally: Path(test_file).unlink(missing_ok=True) @@ -1224,7 +1274,7 @@ def test_send_file_missing_from_address(self, mock_get_password, mock_load_confi mock_storage.is_sent.return_value = False mock_storage_class.return_value = mock_storage result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.CONFIG_ERROR) mock_ui.error.assert_called() finally: Path(test_file).unlink(missing_ok=True) @@ -1379,7 +1429,7 @@ def test_send_folder_missing_smtp_section(self, mock_load_config): result = send_folder( {}, str(self.test_folder), "test@example.com", None, False, "config.yaml", ui ) - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.CONFIG_ERROR) mock_error.assert_called_once() @patch("email_processor.__main__.ConfigLoader") @@ -1396,7 +1446,7 @@ def test_send_folder_init_failed(self, mock_init, mock_load_config): result = send_folder( {"smtp": {}}, str(self.test_folder), "test@example.com", None, False, "config.yaml", ui ) - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.CONFIG_ERROR) @patch("email_processor.__main__.ConfigLoader") @patch("email_processor.cli.commands.smtp.get_imap_password") diff --git a/tests/unit/cli/test_ui.py b/tests/unit/cli/test_ui.py index 89a1f70..7011df7 100644 --- a/tests/unit/cli/test_ui.py +++ b/tests/unit/cli/test_ui.py @@ -8,6 +8,7 @@ from email_processor.__main__ import main from email_processor.cli import CLIUI from email_processor.cli.commands.config import create_default_config +from email_processor.exit_codes import ExitCode from email_processor.imap.fetcher import ProcessingMetrics, ProcessingResult @@ -110,7 +111,9 @@ def test_config_error_with_rich_console(self, mock_config_loader_class): mock_ui_class.return_value = mock_ui with patch("sys.argv", ["email_processor", "run"]): result = main() - self.assertEqual(result, 3) # EXIT_CONFIG_ERROR + from email_processor.exit_codes import ExitCode + + self.assertEqual(result, ExitCode.CONFIG_ERROR) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -124,7 +127,9 @@ def test_config_error_without_rich_console(self, mock_config_loader_class): mock_ui_class.return_value = mock_ui with patch("sys.argv", ["email_processor", "run"]): result = main() - self.assertEqual(result, 3) # EXIT_CONFIG_ERROR + from email_processor.exit_codes import ExitCode + + self.assertEqual(result, ExitCode.CONFIG_ERROR) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -142,7 +147,9 @@ def test_file_not_found_error_with_rich_console(self, mock_config_loader_class): mock_ui_class.return_value = mock_ui with patch("sys.argv", ["email_processor", "run"]): result = main() - self.assertEqual(result, 3) # EXIT_CONFIG_ERROR + from email_processor.exit_codes import ExitCode + + self.assertEqual(result, ExitCode.CONFIG_ERROR) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -158,7 +165,9 @@ def test_unexpected_error_with_rich_console(self, mock_config_loader_class): mock_ui_class.return_value = mock_ui with patch("sys.argv", ["email_processor", "run"]): result = main() - self.assertEqual(result, 3) # EXIT_CONFIG_ERROR + from email_processor.exit_codes import ExitCode + + self.assertEqual(result, ExitCode.CONFIG_ERROR) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -172,7 +181,9 @@ def test_unexpected_error_without_rich_console(self, mock_config_loader_class): mock_ui_class.return_value = mock_ui with patch("sys.argv", ["email_processor", "run"]): result = main() - self.assertEqual(result, 3) # EXIT_CONFIG_ERROR + from email_processor.exit_codes import ExitCode + + self.assertEqual(result, ExitCode.CONFIG_ERROR) mock_ui.error.assert_called() @@ -416,7 +427,16 @@ def test_clear_passwords_missing_user_with_rich_console(self, mock_load_config): ["email_processor", "password", "clear", "--user", "test@example.com"], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -450,7 +470,16 @@ def test_set_password_missing_user_with_rich_console(self, mock_load_config): ], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -478,7 +507,16 @@ def test_set_password_missing_password_file_with_rich_console(self, mock_load_co "sys.argv", ["email_processor", "password", "set", "--user", "test@example.com"] ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -514,7 +552,16 @@ def test_set_password_file_not_found_with_rich_console(self, mock_load_config): ], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -664,7 +711,11 @@ def test_set_password_save_error_with_rich_console( ], ): result = main() - self.assertEqual(result, 4) # EXIT_AUTH_ERROR when keyring save fails + from email_processor.exit_codes import ExitCode + + self.assertEqual( + result, ExitCode.UNSUPPORTED_FORMAT + ) # Authentication/keyring error # Should print error mock_ui.error.assert_called() finally: @@ -864,7 +915,16 @@ def test_smtp_send_file_not_found_with_rich_console( ], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -903,7 +963,16 @@ def test_smtp_send_file_not_a_file_with_rich_console( ["email_processor", "send", "file", temp_dir, "--to", "test@example.com"], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -1134,7 +1203,16 @@ def test_smtp_send_file_failed_with_rich_console( ["email_processor", "send", "file", test_file, "--to", "test@example.com"], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() finally: Path(test_file).unlink(missing_ok=True) @@ -1181,7 +1259,16 @@ def test_smtp_send_folder_not_found_with_rich_console( ], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -1230,7 +1317,16 @@ def test_smtp_send_folder_not_a_directory_with_rich_console( ], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() finally: Path(test_file).unlink(missing_ok=True) @@ -1387,7 +1483,16 @@ def test_smtp_send_missing_config_with_rich_console(self, mock_get_password, moc ["email_processor", "send", "file", "test.txt", "--to", "test@example.com"], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -1418,7 +1523,16 @@ def test_smtp_send_missing_server_with_rich_console(self, mock_get_password, moc ["email_processor", "send", "file", "test.txt", "--to", "test@example.com"], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -1449,7 +1563,16 @@ def test_smtp_send_missing_user_with_rich_console(self, mock_get_password, mock_ ["email_processor", "send", "file", "test.txt", "--to", "test@example.com"], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -1482,7 +1605,16 @@ def test_smtp_send_missing_from_address_with_rich_console( ["email_processor", "send", "file", "test.txt", "--to", "test@example.com"], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -1517,7 +1649,16 @@ def test_smtp_send_missing_recipient_with_rich_console( ["email_processor", "send", "file", "test.txt", "--to", "test@example.com"], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -1551,7 +1692,16 @@ def test_smtp_send_password_error_with_rich_console(self, mock_get_password, moc ["email_processor", "send", "file", "test.txt", "--to", "test@example.com"], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -1596,7 +1746,7 @@ def path_side_effect(path_str): ui = CLIUI() with patch.object(ui, "error") as mock_error: result = create_default_config("config.yaml", ui) - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) mock_error.assert_called_once() @patch("email_processor.cli.commands.config.Path") @@ -1717,7 +1867,7 @@ def test_set_password_file_permission_error_with_rich_console( ], ): result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) mock_ui.error.assert_called() finally: Path(test_file).unlink(missing_ok=True) @@ -1766,7 +1916,7 @@ def test_set_password_file_read_error_with_rich_console( ], ): result = main() - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) mock_ui.error.assert_called() finally: Path(test_file).unlink(missing_ok=True) @@ -1814,7 +1964,16 @@ def test_set_password_file_empty_with_rich_console( ], ): result = main() - self.assertEqual(result, 1) + # Check appropriate exit code based on test context + self.assertIn( + result, + ( + ExitCode.FILE_NOT_FOUND, + ExitCode.VALIDATION_FAILED, + ExitCode.CONFIG_ERROR, + ExitCode.PROCESSING_ERROR, + ), + ) mock_ui.error.assert_called() finally: Path(test_file).unlink(missing_ok=True) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 37274d8..4d84b94 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -7,6 +7,7 @@ from email_processor.__main__ import _parse_duration, _validate_email, main from email_processor.cli import CLIUI from email_processor.cli.commands.config import create_default_config +from email_processor.exit_codes import ExitCode from email_processor.imap.fetcher import ProcessingMetrics, ProcessingResult @@ -24,7 +25,7 @@ def test_main_clear_passwords_mode(self, mock_clear_passwords, mock_config_loade "sys.argv", ["email_processor", "password", "clear", "--user", "test@example.com"] ): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_clear_passwords.assert_called_once_with("test@example.com", unittest.mock.ANY) def test_main_clear_passwords_missing_user(self): @@ -59,7 +60,7 @@ def test_main_normal_mode( with patch("sys.argv", ["email_processor", "run"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_processor.process.assert_called_once_with( dry_run=False, mock_mode=False, config_path="config.yaml" ) @@ -90,7 +91,7 @@ def test_main_dry_run_mode( with patch("sys.argv", ["email_processor", "run", "--dry-run"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_processor.process.assert_called_once_with( dry_run=True, mock_mode=False, config_path="config.yaml" ) @@ -104,7 +105,7 @@ def test_main_config_file_not_found(self, mock_load_config): with patch("sys.argv", ["email_processor", "run"]): result = main() - self.assertEqual(result, 3) # EXIT_CONFIG_ERROR + self.assertEqual(result, ExitCode.CONFIG_ERROR) @patch("email_processor.config.loader.ConfigLoader.load") def test_main_config_validation_error(self, mock_load_config): @@ -113,7 +114,7 @@ def test_main_config_validation_error(self, mock_load_config): with patch("sys.argv", ["email_processor", "run"]): result = main() - self.assertEqual(result, 3) # EXIT_CONFIG_ERROR + self.assertEqual(result, ExitCode.CONFIG_ERROR) @patch("email_processor.config.loader.ConfigLoader.load") @patch("email_processor.imap.auth.get_imap_password") @@ -141,7 +142,7 @@ def test_main_custom_config_path( with patch("sys.argv", ["email_processor", "--config", "custom_config.yaml"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) # ConfigLoader.load is called with ui parameter mock_load_config.assert_called_once() call_args = mock_load_config.call_args @@ -179,7 +180,7 @@ def test_main_with_mock_metrics( with patch("sys.argv", ["email_processor"]): result = main() # Should not crash, even with MagicMock metrics - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_processor.process.assert_called_once_with( dry_run=False, mock_mode=False, config_path="config.yaml" ) @@ -212,7 +213,7 @@ def test_main_with_none_metrics( with patch("sys.argv", ["email_processor"]): result = main() # Should not crash, even with None metrics - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_processor.process.assert_called_once_with( dry_run=False, mock_mode=False, config_path="config.yaml" ) @@ -240,7 +241,7 @@ def test_main_keyboard_interrupt( with patch("sys.argv", ["email_processor"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) @patch("email_processor.config.loader.ConfigLoader.load") @patch("email_processor.imap.auth.get_imap_password") @@ -264,7 +265,9 @@ def test_main_processing_error( with patch("sys.argv", ["email_processor", "run"]): result = main() - self.assertEqual(result, 1) # EXIT_ERROR + from email_processor.exit_codes import ExitCode + + self.assertEqual(result, ExitCode.PROCESSING_ERROR) @patch("email_processor.cli.commands.config.Path") @patch("email_processor.cli.commands.config.shutil.copy2") @@ -300,7 +303,7 @@ def test_create_default_config_example_not_found(self, mock_path_class): ui = CLIUI() with patch.object(ui, "error") as mock_error: result = create_default_config("config.yaml", ui) - self.assertEqual(result, 1) + self.assertEqual(result, ExitCode.FILE_NOT_FOUND) mock_error.assert_called_once() @patch("email_processor.cli.commands.config.Path") @@ -338,7 +341,7 @@ def test_create_default_config_file_exists_cancel(self, mock_path_class): with patch.object(ui, "input", return_value="n"): with patch.object(ui, "warn") as mock_warn: result = create_default_config("config.yaml", ui) - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_warn.assert_called_once_with("Cancelled.") @patch("email_processor.cli.commands.config.Path") @@ -367,7 +370,7 @@ def test_main_create_config_mode(self, mock_create_config): with patch("sys.argv", ["email_processor", "config", "init"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_create_config.assert_called_once() # Check that config_path was passed call_args = mock_create_config.call_args[0] @@ -380,7 +383,7 @@ def test_main_create_config_with_custom_path(self, mock_create_config): with patch("sys.argv", ["email_processor", "config", "init", "--path", "custom.yaml"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_create_config.assert_called_once() # Check that custom path was passed call_args = mock_create_config.call_args[0] @@ -416,7 +419,7 @@ def test_dry_run_no_connect_mode( with patch("sys.argv", ["email_processor", "run", "--dry-run-no-connect"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_processor.process.assert_called_once_with( dry_run=True, mock_mode=True, config_path="config.yaml" ) @@ -454,7 +457,7 @@ def test_smtp_section_missing_warning( mock_logger = MagicMock() mock_get_logger.return_value = mock_logger result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) # Warning is logged via structlog, check that logger.warning was called # The warning is logged through get_logger().warning() which is a structlog bound logger # mock_logger is the return value of get_logger(), so we check mock_logger.warning @@ -521,7 +524,7 @@ def test_send_file_command(self, mock_send_file, mock_load_config): "sys.argv", ["email_processor", "send", "file", "test.txt", "--to", "test@example.com"] ): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_send_file.assert_called_once() @patch("email_processor.config.loader.ConfigLoader.load") @@ -532,7 +535,7 @@ def test_send_file_missing_path(self, mock_load_config): with patch("sys.argv", ["email_processor", "send", "file", "--to", "test@example.com"]): with self.assertRaises(SystemExit) as cm: main() - self.assertEqual(cm.exception.code, 2) # EXIT_INVALID_ARGS from argparse + self.assertEqual(cm.exception.code, ExitCode.VALIDATION_FAILED) # from argparse @patch("email_processor.config.loader.ConfigLoader.load") def test_send_file_missing_to(self, mock_load_config): @@ -542,7 +545,7 @@ def test_send_file_missing_to(self, mock_load_config): with patch("sys.argv", ["email_processor", "send", "file", "test.txt"]): with self.assertRaises(SystemExit) as cm: main() - self.assertEqual(cm.exception.code, 2) # EXIT_INVALID_ARGS from argparse + self.assertEqual(cm.exception.code, ExitCode.VALIDATION_FAILED) # from argparse @patch("email_processor.config.loader.ConfigLoader.load") @patch("email_processor.cli.commands.smtp.send_file") @@ -552,7 +555,7 @@ def test_send_file_invalid_email(self, mock_send_file, mock_load_config): with patch("sys.argv", ["email_processor", "send", "file", "test.txt", "--to", "invalid"]): result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_send_file.assert_not_called() @patch("email_processor.config.loader.ConfigLoader.load") @@ -578,7 +581,7 @@ def test_send_file_with_cc_bcc(self, mock_send_file, mock_load_config): ], ): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_send_file.assert_called_once() @patch("email_processor.config.loader.ConfigLoader.load") @@ -601,7 +604,7 @@ def test_send_file_invalid_cc(self, mock_send_file, mock_load_config): ], ): result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_send_file.assert_not_called() @patch("email_processor.config.loader.ConfigLoader.load") @@ -624,7 +627,7 @@ def test_send_file_invalid_bcc(self, mock_send_file, mock_load_config): ], ): result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_send_file.assert_not_called() @@ -643,7 +646,7 @@ def test_send_folder_command(self, mock_send_folder, mock_load_config): ["email_processor", "send", "folder", "test_dir", "--to", "test@example.com"], ): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_send_folder.assert_called_once() @patch("email_processor.config.loader.ConfigLoader.load") @@ -654,7 +657,7 @@ def test_send_folder_missing_dir(self, mock_load_config): with patch("sys.argv", ["email_processor", "send", "folder", "--to", "test@example.com"]): with self.assertRaises(SystemExit) as cm: main() - self.assertEqual(cm.exception.code, 2) # EXIT_INVALID_ARGS from argparse + self.assertEqual(cm.exception.code, ExitCode.VALIDATION_FAILED) # from argparse @patch("email_processor.config.loader.ConfigLoader.load") def test_send_folder_missing_to(self, mock_load_config): @@ -664,7 +667,7 @@ def test_send_folder_missing_to(self, mock_load_config): with patch("sys.argv", ["email_processor", "send", "folder", "test_dir"]): with self.assertRaises(SystemExit) as cm: main() - self.assertEqual(cm.exception.code, 2) # EXIT_INVALID_ARGS from argparse + self.assertEqual(cm.exception.code, ExitCode.VALIDATION_FAILED) # from argparse @patch("email_processor.config.loader.ConfigLoader.load") @patch("email_processor.cli.commands.smtp.send_folder") @@ -676,7 +679,7 @@ def test_send_folder_invalid_email(self, mock_send_folder, mock_load_config): "sys.argv", ["email_processor", "send", "folder", "test_dir", "--to", "invalid"] ): result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_send_folder.assert_not_called() @@ -692,7 +695,7 @@ def test_fetch_command(self, mock_run_processor, mock_load_config): with patch("sys.argv", ["email_processor", "fetch"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_run_processor.assert_called_once() @patch("email_processor.config.loader.ConfigLoader.load") @@ -704,7 +707,7 @@ def test_fetch_with_since_duration(self, mock_run_processor, mock_load_config): with patch("sys.argv", ["email_processor", "fetch", "--since", "7d"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_run_processor.assert_called_once() # Check that start_days_back was set to 7 call_kwargs = mock_run_processor.call_args[0] @@ -720,7 +723,7 @@ def test_fetch_with_folder(self, mock_run_processor, mock_load_config): with patch("sys.argv", ["email_processor", "fetch", "--folder", "INBOX"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_run_processor.assert_called_once() @patch("email_processor.config.loader.ConfigLoader.load") @@ -732,7 +735,7 @@ def test_fetch_with_max_emails(self, mock_run_processor, mock_load_config): with patch("sys.argv", ["email_processor", "fetch", "--max-emails", "10"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_run_processor.assert_called_once() @patch("email_processor.config.loader.ConfigLoader.load") @@ -744,7 +747,7 @@ def test_fetch_with_dry_run_no_connect(self, mock_run_processor, mock_load_confi with patch("sys.argv", ["email_processor", "fetch", "--dry-run-no-connect"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_run_processor.assert_called_once() # Check that mock_mode is True call_args = mock_run_processor.call_args[0] @@ -763,7 +766,7 @@ def test_run_with_since_duration(self, mock_run_processor, mock_load_config): with patch("sys.argv", ["email_processor", "run", "--since", "14d"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_run_processor.assert_called_once() # Check that start_days_back was set to 14 call_kwargs = mock_run_processor.call_args[0] @@ -779,7 +782,7 @@ def test_run_with_folder(self, mock_run_processor, mock_load_config): with patch("sys.argv", ["email_processor", "run", "--folder", "INBOX"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_run_processor.assert_called_once() @patch("email_processor.config.loader.ConfigLoader.load") @@ -791,7 +794,7 @@ def test_run_with_max_emails(self, mock_run_processor, mock_load_config): with patch("sys.argv", ["email_processor", "run", "--max-emails", "20"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_run_processor.assert_called_once() def test_parse_duration_invalid_unit(self): @@ -810,7 +813,7 @@ def test_config_validate_command(self, mock_validate, mock_loader_class): with patch("sys.argv", ["email_processor", "config", "validate"]): result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_validate.assert_called_once() @patch("email_processor.__main__.ConfigLoader") @@ -824,7 +827,7 @@ def test_config_validate_command_config_error(self, mock_loader_class): mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 3) # EXIT_CONFIG_ERROR + self.assertEqual(result, ExitCode.CONFIG_ERROR) def test_password_set_missing_user(self): """Test password set command with missing user.""" @@ -842,7 +845,7 @@ def test_password_set_missing_user(self): mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() def test_password_clear_missing_user(self): @@ -861,7 +864,7 @@ def test_password_clear_missing_user(self): mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.cli.commands.smtp.send_file") @@ -887,7 +890,7 @@ def test_send_file_missing_path_attribute(self, mock_loader_class, mock_send_fil mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.cli.commands.smtp.send_file") @@ -912,7 +915,7 @@ def test_send_file_missing_to_attribute(self, mock_loader_class, mock_send_file) mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.cli.commands.smtp.send_folder") @@ -937,7 +940,7 @@ def test_send_folder_missing_dir_attribute(self, mock_loader_class, mock_send_fo mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.cli.commands.smtp.send_folder") @@ -962,7 +965,7 @@ def test_send_folder_missing_to_attribute(self, mock_loader_class, mock_send_fol mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.cli.commands.smtp.send_folder") @@ -990,7 +993,7 @@ def test_send_folder_invalid_cc(self, mock_loader_class, mock_send_folder): mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.cli.commands.smtp.send_folder") @@ -1018,7 +1021,7 @@ def test_send_folder_invalid_bcc(self, mock_loader_class, mock_send_folder): mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.__main__.ConfigLoader") @@ -1037,7 +1040,7 @@ def test_unknown_command(self, mock_config_loader_class): mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.__main__.setup_logging") @@ -1170,7 +1173,7 @@ def test_status_command(self, mock_show_status): mock_args.quiet = False mock_parse.return_value = mock_args result = main() - self.assertEqual(result, 0) + self.assertEqual(result, ExitCode.SUCCESS) mock_show_status.assert_called_once() @patch("email_processor.cli.commands.smtp.send_file") @@ -1195,7 +1198,7 @@ def test_send_file_path_none(self, mock_loader_class, mock_send_file): mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.cli.commands.smtp.send_file") @@ -1220,7 +1223,7 @@ def test_send_file_to_none(self, mock_loader_class, mock_send_file): mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.cli.commands.smtp.send_folder") @@ -1245,7 +1248,7 @@ def test_send_folder_dir_none(self, mock_loader_class, mock_send_folder): mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called() @patch("email_processor.cli.commands.smtp.send_folder") @@ -1270,5 +1273,5 @@ def test_send_folder_to_none(self, mock_loader_class, mock_send_folder): mock_ui = MagicMock() mock_ui_class.return_value = mock_ui result = main() - self.assertEqual(result, 2) # EXIT_INVALID_ARGS + self.assertEqual(result, ExitCode.VALIDATION_FAILED) mock_ui.error.assert_called()