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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 102 additions & 13 deletions passifypdf/cli.py
Original file line number Diff line number Diff line change
@@ -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
"""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()
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using @app.command() creates a subcommand, which means users might need to run passifypdf encrypt --input ... instead of passifypdf --input .... This breaks backward compatibility with the documented usage in README.md (line 40) which shows direct option usage without a subcommand. To maintain backward compatibility and provide a simpler CLI, use @app.callback() instead of @app.command(), or use app.command(name="") to make it the default command that doesn't require typing "encrypt".

Suggested change
@app.command()
@app.callback()

Copilot uses AI. Check for mistakes.
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)
Comment on lines +91 to +97
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI uses logger.info() and logger.error() but logging is never configured, so these messages won't be visible to users. The old implementation used print() to display messages to stdout/stderr. Either switch back to using typer.echo() and typer.secho() (which are Typer's recommended output methods), or add logging configuration. For a CLI tool, typer.echo() is more appropriate for user-facing messages.

Copilot uses AI. Check for mistakes.


def get_typer_app() -> typer.Typer:
"""Return the configured Typer application instance."""
return app
25 changes: 5 additions & 20 deletions passifypdf/encryptpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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())
main()
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
186 changes: 186 additions & 0 deletions tests/unittests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""Unit tests for the Typer-based CLI module."""

from pathlib import Path
from typing import Generator
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Generator type is imported but never used in the test file. Remove this unused import.

Suggested change
from typing import Generator

Copilot uses AI. Check for mistakes.
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)
3 changes: 3 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading