diff --git a/passifypdf/cli.py b/passifypdf/cli.py index 7364188..ddb9c65 100644 --- a/passifypdf/cli.py +++ b/passifypdf/cli.py @@ -1,13 +1,102 @@ -import argparse - - -def get_arg_parser() -> argparse.ArgumentParser: - arg_parser = argparse.ArgumentParser( - description="Encrypt a PDF file with a password of your choice.", - epilog="For more information, visit: https://github.com/SUPAIDEAS/passifypdf" - ) - arg_parser.add_argument("-v", "--version", action="version", version="%(prog)s 1.0") - arg_parser.add_argument("-i", "--input", required=True, help="Path to the input PDF file to be encrypted") - arg_parser.add_argument("-o", "--output", required=True, help="Path where the encrypted PDF file will be saved") - arg_parser.add_argument("-p", "--passwd", required=True, type=str, help="Password to encrypt the PDF file with") - return arg_parser \ No newline at end of file +"""CLI module using Typer for the passifypdf tool.""" + +import logging +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +import typer +from typing_extensions import Annotated + +from passifypdf.encryptpdf import encrypt_pdf + +logger = logging.getLogger(__name__) + +try: + __version__ = version("passifypdf") +except PackageNotFoundError: + __version__ = "unknown" + +app = typer.Typer( + name="passifypdf", + help="Encrypt a PDF file with a password of your choice.", + epilog="For more information, visit: https://github.com/SUPAIDEAS/passifypdf", + add_completion=False, +) + + +def version_callback(value: bool) -> None: + """Print the version and exit.""" + if value: + typer.echo(f"passifypdf {__version__}") + raise typer.Exit() + + +@app.command() +def encrypt( + input: Annotated[ + Path, + typer.Option( + "--input", "-i", + help="Path to the input PDF file to be encrypted.", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + ), + ], + output: Annotated[ + Path, + typer.Option( + "--output", "-o", + help="Path where the encrypted PDF file will be saved.", + ), + ], + passwd: Annotated[ + str, + typer.Option( + "--passwd", "-p", + help="Password to encrypt the PDF file with.", + ), + ], + force: Annotated[ + bool, + typer.Option( + "--force", "-f", + help="Overwrite the output file if it already exists without prompting.", + ), + ] = False, + version: Annotated[ + bool, + typer.Option( + "--version", "-v", + help="Show the version and exit.", + callback=version_callback, + is_eager=True, + ), + ] = False, +) -> None: + """Encrypt a PDF file with a password of your choice.""" + + if output.exists() and not force: + overwrite = typer.confirm( + f"File '{output}' already exists. Overwrite?", + default=False, + ) + if not overwrite: + logger.info("Operation cancelled.") + raise typer.Exit() + + try: + encrypt_pdf(input, output, passwd) + logger.info( + "Congratulations!\nPDF file encrypted successfully and saved as '%s'", + output, + ) + except Exception as e: + logger.error("Error: %s", e) + raise typer.Exit(code=1) + + +def get_typer_app() -> typer.Typer: + """Return the configured Typer application instance.""" + return app \ No newline at end of file diff --git a/passifypdf/encryptpdf.py b/passifypdf/encryptpdf.py index e326cab..12509d1 100644 --- a/passifypdf/encryptpdf.py +++ b/passifypdf/encryptpdf.py @@ -6,8 +6,6 @@ from pypdf import PdfReader, PdfWriter -from .cli import get_arg_parser - def encrypt_pdf(input_pdf: Union[str, Path], output_pdf: Union[str, Path], password: str) -> None: """ @@ -49,24 +47,11 @@ def encrypt_pdf(input_pdf: Union[str, Path], output_pdf: Union[str, Path], passw raise Exception(f"Failed to encrypt PDF: {e}") -def main() -> int: - """ - Main function to run the CLI. - - Returns: - int: Exit code (0 for success, 1 for failure). - """ - arg_parser = get_arg_parser() - args = arg_parser.parse_args() - - try: - encrypt_pdf(args.input, args.output, args.passwd) - print(f"Congratulations!\nPDF file encrypted successfully and saved as '{args.output}'") - return 0 - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - return 1 +def main() -> None: + """Entry point: delegates to the Typer CLI application.""" + from passifypdf.cli import app + app() if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0aa8348..f945686 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ packages = [{include = "passifypdf"}] [tool.poetry.dependencies] python = "^3.8" pypdf = "^4.3.1" +typer = {extras = ["standard"], version = "^0.12.0"} +typing-extensions = "^4.0" [tool.poetry.group.dev.dependencies] coverage = "^7.0" diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py new file mode 100644 index 0000000..bfed58c --- /dev/null +++ b/tests/unittests/test_cli.py @@ -0,0 +1,186 @@ +"""Unit tests for the Typer-based CLI module.""" + +from pathlib import Path +from typing import Generator +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from passifypdf.cli import app + + +class TestTyperCli(TestCase): + """Tests for the Typer CLI application.""" + + def setUp(self) -> None: + """Set up test fixtures.""" + self.runner = CliRunner() + + # ------------------------------------------------------------------ + # --help + # ------------------------------------------------------------------ + + def test_help_flag(self) -> None: + """Test that --help exits 0 and mentions key options.""" + result = self.runner.invoke(app, ["--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("--input", result.output) + self.assertIn("--output", result.output) + self.assertIn("--passwd", result.output) + + # ------------------------------------------------------------------ + # --version + # ------------------------------------------------------------------ + + def test_version_flag(self) -> None: + """Test that --version exits 0 and prints the version.""" + result = self.runner.invoke(app, ["--version"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("passifypdf", result.output) + + # ------------------------------------------------------------------ + # Happy path + # ------------------------------------------------------------------ + + @patch("passifypdf.cli.encrypt_pdf") + def test_encrypt_success(self, mock_encrypt: MagicMock) -> None: + """Test a successful encryption invocation.""" + with self.runner.isolated_filesystem(): + # Create a dummy input file so Typer's `exists=True` check passes + Path("input.pdf").write_bytes(b"%PDF-1.4 dummy") + result = self.runner.invoke( + app, + [ + "--input", "input.pdf", + "--output", "output.pdf", + "--passwd", "secret", + "--force", + ], + ) + self.assertEqual(result.exit_code, 0) + mock_encrypt.assert_called_once() + + # ------------------------------------------------------------------ + # Output collision — --force bypasses prompt + # ------------------------------------------------------------------ + + @patch("passifypdf.cli.encrypt_pdf") + def test_force_flag_skips_prompt(self, mock_encrypt: MagicMock) -> None: + """--force skips the overwrite prompt when output already exists.""" + with self.runner.isolated_filesystem(): + Path("input.pdf").write_bytes(b"%PDF-1.4 dummy") + Path("output.pdf").write_bytes(b"existing") + result = self.runner.invoke( + app, + [ + "--input", "input.pdf", + "--output", "output.pdf", + "--passwd", "secret", + "--force", + ], + ) + self.assertEqual(result.exit_code, 0) + mock_encrypt.assert_called_once() + + # ------------------------------------------------------------------ + # Output collision — user says no + # ------------------------------------------------------------------ + + @patch("passifypdf.cli.encrypt_pdf") + def test_no_force_user_declines(self, mock_encrypt: MagicMock) -> None: + """Without --force, declining the prompt should cancel the operation.""" + with self.runner.isolated_filesystem(): + Path("input.pdf").write_bytes(b"%PDF-1.4 dummy") + Path("output.pdf").write_bytes(b"existing") + # Provide 'n' as user input to the prompt + result = self.runner.invoke( + app, + [ + "--input", "input.pdf", + "--output", "output.pdf", + "--passwd", "secret", + ], + input="n\n", + ) + self.assertEqual(result.exit_code, 0) + mock_encrypt.assert_not_called() + + # ------------------------------------------------------------------ + # Output collision — user says yes + # ------------------------------------------------------------------ + + @patch("passifypdf.cli.encrypt_pdf") + def test_no_force_user_accepts(self, mock_encrypt: MagicMock) -> None: + """Without --force, accepting the prompt should proceed with encryption.""" + with self.runner.isolated_filesystem(): + Path("input.pdf").write_bytes(b"%PDF-1.4 dummy") + Path("output.pdf").write_bytes(b"existing") + result = self.runner.invoke( + app, + [ + "--input", "input.pdf", + "--output", "output.pdf", + "--passwd", "secret", + ], + input="y\n", + ) + self.assertEqual(result.exit_code, 0) + mock_encrypt.assert_called_once() + + # ------------------------------------------------------------------ + # Missing required options + # ------------------------------------------------------------------ + + def test_missing_required_options(self) -> None: + """Omitting required options should exit with a non-zero code.""" + result = self.runner.invoke(app, []) + self.assertNotEqual(result.exit_code, 0) + + # ------------------------------------------------------------------ + # Non-existent input file + # ------------------------------------------------------------------ + + def test_input_file_not_found(self) -> None: + """Passing a non-existent input file should exit with an error.""" + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + app, + [ + "--input", "ghost.pdf", + "--output", "output.pdf", + "--passwd", "secret", + ], + ) + self.assertNotEqual(result.exit_code, 0) + + # ------------------------------------------------------------------ + # encrypt_pdf raises an error + # ------------------------------------------------------------------ + + @patch("passifypdf.cli.encrypt_pdf") + def test_encrypt_failure_exits_with_1(self, mock_encrypt: MagicMock) -> None: + """If encrypt_pdf raises an exception, CLI should exit with code 1.""" + mock_encrypt.side_effect = Exception("boom") + with self.runner.isolated_filesystem(): + Path("input.pdf").write_bytes(b"%PDF-1.4 dummy") + result = self.runner.invoke( + app, + [ + "--input", "input.pdf", + "--output", "output.pdf", + "--passwd", "secret", + "--force", + ], + ) + self.assertEqual(result.exit_code, 1) + + # ------------------------------------------------------------------ + # get_typer_app helper + # ------------------------------------------------------------------ + + def test_get_typer_app_returns_app(self) -> None: + """get_typer_app() should return the configured Typer instance.""" + from passifypdf.cli import get_typer_app + result = get_typer_app() + self.assertIs(result, app) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bda0207 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.13"