Skip to content

Commit f187bee

Browse files
authored
fix: Improve typings for errors (#213)
* fix: Clarify version docs * fix: Add the test that verifies direct instance throws * fix: Add the tests for the factory method * fix: Add tests for from_asset * fix: Add tests * fix: Typos in docs * fix: Add docs * fix: Docs * fix: Typo * fix: Rename method * fix: Exception classes hierarchy * fix: COmment typos... * fix: Typos in comments * fix: In examples, add link to the app repo example * fix: In examples, add link to the app repo example * fix: In examples, add link to the app repo example * fix: Add docs link * fix: Add docs link * fix: Clean up exception handling
1 parent 3d8387f commit f187bee

4 files changed

Lines changed: 197 additions & 90 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/m
3939

4040
## API reference documentation
4141

42-
See [the section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation).
42+
Documentation is published at [github.io/c2pa-python/api/c2pa](https://contentauth.github.io/c2pa-python/api/c2pa/index.html).
43+
44+
To build documentation locally, refer to [this section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation).
4345

4446
## Contributing
4547

examples/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Python example code
1+
# Python example code
22

33
The `examples` directory contains some small examples of using the Python library.
44
The examples use asset files from the `tests/fixtures` directory, save the resulting signed assets to the temporary `output` directory, and display manifest store data and other output to the console.
@@ -96,3 +96,7 @@ In this example, `SignerInfo` creates a `Signer` object that signs the manifest.
9696
```bash
9797
python examples/sign_info.py
9898
```
99+
100+
## Backend application example
101+
102+
[c2pa-python-example](https://github.com/contentauth/c2pa-python-example) is an example of a simple application that accepts an uploaded JPEG image file, attaches a C2PA manifest, and signs it using a certificate. The app uses the CAI Python library and the Flask Python framework to implement a back-end REST endpoint; it does not have an HTML front-end, so you have to use something like curl to access it. This example is a development setup and should not be deployed as-is to a production environment.

src/c2pa/c2pa.py

Lines changed: 181 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,6 @@
7171
'c2pa_reader_remote_url',
7272
]
7373

74-
# TODO Bindings:
75-
# c2pa_reader_is_embedded
76-
# c2pa_reader_remote_url
77-
7874

7975
def _validate_library_exports(lib):
8076
"""Validate that all required functions are present in the loaded library.
@@ -537,71 +533,118 @@ def _setup_function(func, argtypes, restype=None):
537533

538534

539535
class C2paError(Exception):
540-
"""Exception raised for C2PA errors."""
536+
"""Exception raised for C2PA errors.
537+
538+
This is the base class for all C2PA exceptions. Catching C2paError will
539+
catch all typed C2PA exceptions (e.g., C2paError.ManifestNotFound).
540+
"""
541541

542542
def __init__(self, message: str = ""):
543543
self.message = message
544544
super().__init__(message)
545545

546-
class Assertion(Exception):
547-
"""Exception raised for assertion errors."""
548-
pass
549546

550-
class AssertionNotFound(Exception):
551-
"""Exception raised when an assertion is not found."""
552-
pass
547+
# Define typed exception subclasses that inherit from C2paError
548+
# These are attached to C2paError as class attributes for backward compatibility
549+
# (eg., C2paError.ManifestNotFound), and also to ensure properly inheritance hierarchy
550+
551+
class _C2paAssertion(C2paError):
552+
"""Exception raised for assertion errors."""
553+
pass
554+
555+
556+
class _C2paAssertionNotFound(C2paError):
557+
"""Exception raised when an assertion is not found."""
558+
pass
559+
560+
561+
class _C2paDecoding(C2paError):
562+
"""Exception raised for decoding errors."""
563+
pass
564+
565+
566+
class _C2paEncoding(C2paError):
567+
"""Exception raised for encoding errors."""
568+
pass
569+
570+
571+
class _C2paFileNotFound(C2paError):
572+
"""Exception raised when a file is not found."""
573+
pass
574+
575+
576+
class _C2paIo(C2paError):
577+
"""Exception raised for IO errors."""
578+
pass
579+
580+
581+
class _C2paJson(C2paError):
582+
"""Exception raised for JSON errors."""
583+
pass
584+
585+
586+
class _C2paManifest(C2paError):
587+
"""Exception raised for manifest errors."""
588+
pass
589+
590+
591+
class _C2paManifestNotFound(C2paError):
592+
"""
593+
Exception raised when a manifest is not found,
594+
aka there is no C2PA metadata to read
595+
aka there is no JUMBF data to read.
596+
"""
597+
pass
598+
553599

554-
class Decoding(Exception):
555-
"""Exception raised for decoding errors."""
556-
pass
600+
class _C2paNotSupported(C2paError):
601+
"""Exception raised for unsupported operations."""
602+
pass
557603

558-
class Encoding(Exception):
559-
"""Exception raised for encoding errors."""
560-
pass
561604

562-
class FileNotFound(Exception):
563-
"""Exception raised when a file is not found."""
564-
pass
605+
class _C2paOther(C2paError):
606+
"""Exception raised for other errors."""
607+
pass
565608

566-
class Io(Exception):
567-
"""Exception raised for IO errors."""
568-
pass
569609

570-
class Json(Exception):
571-
"""Exception raised for JSON errors."""
572-
pass
610+
class _C2paRemoteManifest(C2paError):
611+
"""Exception raised for remote manifest errors."""
612+
pass
573613

574-
class Manifest(Exception):
575-
"""Exception raised for manifest errors."""
576-
pass
577614

578-
class ManifestNotFound(Exception):
579-
"""Exception raised when a manifest is not found."""
580-
pass
615+
class _C2paResourceNotFound(C2paError):
616+
"""Exception raised when a resource is not found."""
617+
pass
581618

582-
class NotSupported(Exception):
583-
"""Exception raised for unsupported operations."""
584-
pass
585619

586-
class Other(Exception):
587-
"""Exception raised for other errors."""
588-
pass
620+
class _C2paSignature(C2paError):
621+
"""Exception raised for signature errors."""
622+
pass
589623

590-
class RemoteManifest(Exception):
591-
"""Exception raised for remote manifest errors."""
592-
pass
593624

594-
class ResourceNotFound(Exception):
595-
"""Exception raised when a resource is not found."""
596-
pass
625+
class _C2paVerify(C2paError):
626+
"""Exception raised for verification errors."""
627+
pass
597628

598-
class Signature(Exception):
599-
"""Exception raised for signature errors."""
600-
pass
601629

602-
class Verify(Exception):
603-
"""Exception raised for verification errors."""
604-
pass
630+
# Attach exception subclasses to C2paError for backward compatibility
631+
# Preserves behavior for exception catching like except C2paError.ManifestNotFound,
632+
# also reduces imports (think of it as an alias of sorts)
633+
C2paError.Assertion = _C2paAssertion
634+
C2paError.AssertionNotFound = _C2paAssertionNotFound
635+
C2paError.Decoding = _C2paDecoding
636+
C2paError.Encoding = _C2paEncoding
637+
C2paError.FileNotFound = _C2paFileNotFound
638+
C2paError.Io = _C2paIo
639+
C2paError.Json = _C2paJson
640+
C2paError.Manifest = _C2paManifest
641+
C2paError.ManifestNotFound = _C2paManifestNotFound
642+
C2paError.NotSupported = _C2paNotSupported
643+
C2paError.Other = _C2paOther
644+
C2paError.RemoteManifest = _C2paRemoteManifest
645+
C2paError.ResourceNotFound = _C2paResourceNotFound
646+
C2paError.Signature = _C2paSignature
647+
C2paError.Verify = _C2paVerify
605648

606649

607650
class _StringContainer:
@@ -656,60 +699,106 @@ def _convert_to_py_string(value) -> str:
656699
return py_string
657700

658701

702+
def _raise_typed_c2pa_error(error_str: str) -> None:
703+
"""Parse an error string and raise the appropriate typed C2paError.
704+
705+
Error strings from the native library have the format "ErrorType: message".
706+
This function parses the error type and raises the corresponding
707+
C2paError subclass with the full original error string as the message.
708+
709+
Args:
710+
error_str: The error string from the native library
711+
712+
Raises:
713+
C2paError subclass: The appropriate typed exception based on error_str
714+
"""
715+
# Error format from native library is "ErrorType: message" or "ErrorType message"
716+
# Try splitting on ": " first (colon-space), then fall back to space only
717+
if ': ' in error_str:
718+
parts = error_str.split(': ', 1)
719+
else:
720+
parts = error_str.split(' ', 1)
721+
if len(parts) > 1:
722+
error_type = parts[0]
723+
# Use the full error string as the message for backward compatibility
724+
if error_type == "Assertion":
725+
raise C2paError.Assertion(error_str)
726+
elif error_type == "AssertionNotFound":
727+
raise C2paError.AssertionNotFound(error_str)
728+
elif error_type == "Decoding":
729+
raise C2paError.Decoding(error_str)
730+
elif error_type == "Encoding":
731+
raise C2paError.Encoding(error_str)
732+
elif error_type == "FileNotFound":
733+
raise C2paError.FileNotFound(error_str)
734+
elif error_type == "Io":
735+
raise C2paError.Io(error_str)
736+
elif error_type == "Json":
737+
raise C2paError.Json(error_str)
738+
elif error_type == "Manifest":
739+
raise C2paError.Manifest(error_str)
740+
elif error_type == "ManifestNotFound":
741+
raise C2paError.ManifestNotFound(error_str)
742+
elif error_type == "NotSupported":
743+
raise C2paError.NotSupported(error_str)
744+
elif error_type == "Other":
745+
raise C2paError.Other(error_str)
746+
elif error_type == "RemoteManifest":
747+
raise C2paError.RemoteManifest(error_str)
748+
elif error_type == "ResourceNotFound":
749+
raise C2paError.ResourceNotFound(error_str)
750+
elif error_type == "Signature":
751+
raise C2paError.Signature(error_str)
752+
elif error_type == "Verify":
753+
raise C2paError.Verify(error_str)
754+
# If no recognized error type, raise base C2paError
755+
raise C2paError(error_str)
756+
757+
659758
def _parse_operation_result_for_error(
660759
result: ctypes.c_void_p | None,
661760
check_error: bool = True) -> Optional[str]:
662-
"""Helper function to handle string results from C2PA functions."""
761+
"""Helper function to handle string results from C2PA functions.
762+
763+
When result is falsy and check_error is True, this function retrieves the
764+
error from the native library, parses it, and raises a typed C2paError.
765+
766+
When result is truthy (a pointer to an error string), this function
767+
converts it to a Python string, parses it, and raises a typed C2paError.
768+
769+
Args:
770+
result: A pointer to a result string, or None/falsy on error
771+
check_error: Whether to check for errors when result is falsy
772+
773+
Returns:
774+
None if no error occurred
775+
776+
Raises:
777+
C2paError subclass: The appropriate typed exception if an error occurred
778+
"""
663779
if not result: # pragma: no cover
664780
if check_error:
665781
error = _lib.c2pa_error()
666782
if error:
667783
error_str = ctypes.cast(
668784
error, ctypes.c_char_p).value.decode('utf-8')
669785
_lib.c2pa_string_free(error)
670-
parts = error_str.split(' ', 1)
671-
if len(parts) > 1:
672-
error_type, message = parts
673-
if error_type == "Assertion":
674-
raise C2paError.Assertion(message)
675-
elif error_type == "AssertionNotFound":
676-
raise C2paError.AssertionNotFound(message)
677-
elif error_type == "Decoding":
678-
raise C2paError.Decoding(message)
679-
elif error_type == "Encoding":
680-
raise C2paError.Encoding(message)
681-
elif error_type == "FileNotFound":
682-
raise C2paError.FileNotFound(message)
683-
elif error_type == "Io":
684-
raise C2paError.Io(message)
685-
elif error_type == "Json":
686-
raise C2paError.Json(message)
687-
elif error_type == "Manifest":
688-
raise C2paError.Manifest(message)
689-
elif error_type == "ManifestNotFound":
690-
raise C2paError.ManifestNotFound(message)
691-
elif error_type == "NotSupported":
692-
raise C2paError.NotSupported(message)
693-
elif error_type == "Other":
694-
raise C2paError.Other(message)
695-
elif error_type == "RemoteManifest":
696-
raise C2paError.RemoteManifest(message)
697-
elif error_type == "ResourceNotFound":
698-
raise C2paError.ResourceNotFound(message)
699-
elif error_type == "Signature":
700-
raise C2paError.Signature(message)
701-
elif error_type == "Verify":
702-
raise C2paError.Verify(message)
703-
return error_str
786+
_raise_typed_c2pa_error(error_str)
704787
return None
705788

706789
# In the case result would be a string already (error message)
707-
return _convert_to_py_string(result)
790+
error_str = _convert_to_py_string(result)
791+
if error_str:
792+
_raise_typed_c2pa_error(error_str)
793+
return None
708794

709795

710796
def sdk_version() -> str:
711797
"""
712798
Returns the underlying c2pa-rs/c2pa-c-ffi version string
799+
c2pa-rs and c2pa-c-ffi versions are in lockstep release,
800+
so the version string is the same for both and we return
801+
the shared semantic version number.
713802
"""
714803
vstr = version()
715804
# Example: "c2pa-c/0.60.1 c2pa-rs/0.60.1"
@@ -721,7 +810,11 @@ def sdk_version() -> str:
721810

722811

723812
def version() -> str:
724-
"""Get the C2PA library version."""
813+
"""
814+
Get the C2PA library version with the fully qualified names
815+
of the native core libraries (library names and semantic version
816+
numbers).
817+
"""
725818
result = _lib.c2pa_version()
726819
return _convert_to_py_string(result)
727820

@@ -2622,7 +2715,7 @@ def set_intent(
26222715
- EDIT: Edit of a pre-existing parent asset.
26232716
Must have a parent ingredient.
26242717
- UPDATE: Restricted version of Edit for non-editorial changes.
2625-
Must have only one ingredient as a parent.
2718+
Must have only one ingredient, as a parent.
26262719
26272720
Args:
26282721
intent: The builder intent (C2paBuilderIntent enum value)

tests/test_unit_tests.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ def test_can_retrieve_reader_supported_mimetypes(self):
8585

8686
self.assertEqual(result1, result2)
8787

88+
def test_stream_read_nothing_to_read(self):
89+
# The ingredient test file has no manifest
90+
# So if we instantiate directly, the Reader instance should throw
91+
with open(INGREDIENT_TEST_FILE, "rb") as file:
92+
with self.assertRaises(Error) as context:
93+
reader = Reader("image/jpeg", file)
94+
self.assertIn("ManifestNotFound: no JUMBF data found", str(context.exception))
95+
8896
def test_stream_read(self):
8997
with open(self.testPath, "rb") as file:
9098
reader = Reader("image/jpeg", file)

0 commit comments

Comments
 (0)