Repository: py-pdf/pypdf
Pull request: MAINT: Introduce WrongPasswordError / FileNotDecryptedError / EmptyFileError
closes https://github.com/py-pdf/PyPDF2/issues/1200

# Story:
- Would be good to have clearer distinction of some well defined errors (that we want to detect) from the generic `PdfReadError`:
    - WrongPasswordError
    - FileNotDecryptedError
    - EmptyFileError
- This makes it cleaner for error catching and handling (instead of inspecting the args within the error message)

Problem statement:
Clearer distinction of some well defined errors
## Explanation

- Would be good to have clearer distinction of some well defined errors (that we want to detect) from the generic `PdfReadError`:
    - `WrongPasswordError`
    - `FileNotDecryptedError`
    - `EmptyFileError`
- This makes it cleaner for error catching and handling (instead of inspecting the args within the error message)

## Code Example

```python
from PyPDF2 import PdfReader, PdfWriter
from PyPDF2.errors import FileNotDecryptedError

filepath = "encrypted.pdf"
try:
    PdfReader(filepath)
except FileNotDecryptedError:
    # do something...specifically for this error!
    # and not have to dig through the args of the error to pick out this specific error...

```

Hints from issue discussion:

Commit message: address comment

Patch to review:
diff --git a/PyPDF2/_reader.py b/PyPDF2/_reader.py
index 03c5bac9e..d2a080611 100644
--- a/PyPDF2/_reader.py
+++ b/PyPDF2/_reader.py
@@ -70,7 +70,8 @@
 from .constants import PageAttributes as PG
 from .constants import PagesAttributes as PA
 from .constants import TrailerKeys as TK
-from .errors import PdfReadError, PdfStreamError
+from .errors import PdfReadError, PdfStreamError, WrongPasswordError, \
+    FileNotDecryptedError, EmptyFileError
 from .generic import (
     ArrayObject,
     ContentStream,
@@ -290,7 +291,7 @@ def __init__(
                 and password is not None
             ):
                 # raise if password provided
-                raise PdfReadError("Wrong password")
+                raise WrongPasswordError("Wrong password")
             self._override_encryption = False
         else:
             if password is not None:
@@ -1155,7 +1156,7 @@ def get_object(self, indirect_reference: IndirectObject) -> Optional[PdfObject]:
             if not self._override_encryption and self._encryption is not None:
                 # if we don't have the encryption key:
                 if not self._encryption.is_decrypted():
-                    raise PdfReadError("File has not been decrypted")
+                    raise FileNotDecryptedError("File has not been decrypted")
                 # otherwise, decrypt here...
                 retval = cast(PdfObject, retval)
                 retval = self._encryption.decrypt_object(
@@ -1306,7 +1307,7 @@ def _basic_validation(self, stream: StreamType) -> None:
         # start at the end:
         stream.seek(0, os.SEEK_END)
         if not stream.tell():
-            raise PdfReadError("Cannot read an empty file")
+            raise EmptyFileError("Cannot read an empty file")
         if self.strict:
             stream.seek(0, os.SEEK_SET)
             header_byte = stream.read(5)
diff --git a/PyPDF2/errors.py b/PyPDF2/errors.py
index 9ed33150b..d00bc7c12 100644
--- a/PyPDF2/errors.py
+++ b/PyPDF2/errors.py
@@ -33,4 +33,16 @@ class ParseError(Exception):
     pass
 
 
+class FileNotDecryptedError(PdfReadError):
+    pass
+
+
+class WrongPasswordError(FileNotDecryptedError):
+    pass
+
+
+class EmptyFileError(PdfReadError):
+    pass
+
+
 STREAM_TRUNCATED_PREMATURELY = "Stream has ended unexpectedly"
diff --git a/tests/test_reader.py b/tests/test_reader.py
index 4dcf38020..027cb4055 100644
--- a/tests/test_reader.py
+++ b/tests/test_reader.py
@@ -11,7 +11,7 @@
 from PyPDF2.constants import ImageAttributes as IA
 from PyPDF2.constants import PageAttributes as PG
 from PyPDF2.constants import Ressources as RES
-from PyPDF2.errors import PdfReadError, PdfReadWarning
+from PyPDF2.errors import PdfReadError, PdfReadWarning, EmptyFileError, FileNotDecryptedError, WrongPasswordError
 from PyPDF2.filters import _xobj_to_image
 from PyPDF2.generic import Destination
 
@@ -414,9 +414,8 @@ def test_get_page_mode(src, expected):
 
 
 def test_read_empty():
-    with pytest.raises(PdfReadError) as exc:
+    with pytest.raises(EmptyFileError):
         PdfReader(io.BytesIO())
-    assert exc.value.args[0] == "Cannot read an empty file"
 
 
 def test_read_malformed_header():
@@ -560,9 +559,8 @@ def test_read_unknown_zero_pages(caplog):
 def test_read_encrypted_without_decryption():
     src = RESOURCE_ROOT / "libreoffice-writer-password.pdf"
     reader = PdfReader(src)
-    with pytest.raises(PdfReadError) as exc:
+    with pytest.raises(FileNotDecryptedError):
         len(reader.pages)
-    assert exc.value.args[0] == "File has not been decrypted"
 
 
 def test_get_destination_page_number():
@@ -1066,3 +1064,12 @@ def test_PdfReaderMultipleDefinitions(caplog):
     assert normalize_warnings(caplog.text) == [
         "Multiple definitions in dictionary at byte 0xb5 for key /Group"
     ]
+
+
+def test_wrong_password_error():
+    encrypted_pdf_path = RESOURCE_ROOT / "encrypted-file.pdf"
+    with pytest.raises(WrongPasswordError):
+        PdfReader(
+            encrypted_pdf_path,
+            password="definitely_the_wrong_password!",
+        )


Code review comments:
diff_hunk: @@ -414,9 +414,8 @@ def test_get_page_mode(src, expected):
 
 
 def test_read_empty():
-    with pytest.raises(PdfReadError) as exc:
+    with pytest.raises(EmptyFileError):
         PdfReader(io.BytesIO())
-    assert exc.value.args[0] == "Cannot read an empty file"
path: tests/test_reader.py
text: @user1:
Please leave the comparison in the test. I like the fact that users can now rely on the more specific exception, but I also want to see that the message is fine 

@author:
done

diff_hunk: @@ -560,9 +559,8 @@ def test_read_unknown_zero_pages(caplog):
 def test_read_encrypted_without_decryption():
     src = RESOURCE_ROOT / "libreoffice-writer-password.pdf"
     reader = PdfReader(src)
-    with pytest.raises(PdfReadError) as exc:
+    with pytest.raises(FileNotDecryptedError):
         len(reader.pages)
-    assert exc.value.args[0] == "File has not been decrypted"
path: tests/test_reader.py
text: @user1:
Please add this back

@author:
done