diff --git a/README.md b/README.md index d33472ee..c2d20851 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/m ## API reference documentation -See [the section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation). +Documentation is published at [github.io/c2pa-python/api/c2pa](https://contentauth.github.io/c2pa-python/api/c2pa/index.html). + +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). ## Contributing diff --git a/examples/README.md b/examples/README.md index ce8003b9..027da526 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,4 +1,4 @@ -# Python example code +# Python example code The `examples` directory contains some small examples of using the Python library. 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. ```bash python examples/sign_info.py ``` + +## Backend application example + +[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. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 82cad35c..7f2f4325 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -71,10 +71,6 @@ 'c2pa_reader_remote_url', ] -# TODO Bindings: -# c2pa_reader_is_embedded -# c2pa_reader_remote_url - def _validate_library_exports(lib): """Validate that all required functions are present in the loaded library. @@ -537,71 +533,118 @@ def _setup_function(func, argtypes, restype=None): class C2paError(Exception): - """Exception raised for C2PA errors.""" + """Exception raised for C2PA errors. + + This is the base class for all C2PA exceptions. Catching C2paError will + catch all typed C2PA exceptions (e.g., C2paError.ManifestNotFound). + """ def __init__(self, message: str = ""): self.message = message super().__init__(message) - class Assertion(Exception): - """Exception raised for assertion errors.""" - pass - class AssertionNotFound(Exception): - """Exception raised when an assertion is not found.""" - pass +# Define typed exception subclasses that inherit from C2paError +# These are attached to C2paError as class attributes for backward compatibility +# (eg., C2paError.ManifestNotFound), and also to ensure properly inheritance hierarchy + +class _C2paAssertion(C2paError): + """Exception raised for assertion errors.""" + pass + + +class _C2paAssertionNotFound(C2paError): + """Exception raised when an assertion is not found.""" + pass + + +class _C2paDecoding(C2paError): + """Exception raised for decoding errors.""" + pass + + +class _C2paEncoding(C2paError): + """Exception raised for encoding errors.""" + pass + + +class _C2paFileNotFound(C2paError): + """Exception raised when a file is not found.""" + pass + + +class _C2paIo(C2paError): + """Exception raised for IO errors.""" + pass + + +class _C2paJson(C2paError): + """Exception raised for JSON errors.""" + pass + + +class _C2paManifest(C2paError): + """Exception raised for manifest errors.""" + pass + + +class _C2paManifestNotFound(C2paError): + """ + Exception raised when a manifest is not found, + aka there is no C2PA metadata to read + aka there is no JUMBF data to read. + """ + pass + - class Decoding(Exception): - """Exception raised for decoding errors.""" - pass +class _C2paNotSupported(C2paError): + """Exception raised for unsupported operations.""" + pass - class Encoding(Exception): - """Exception raised for encoding errors.""" - pass - class FileNotFound(Exception): - """Exception raised when a file is not found.""" - pass +class _C2paOther(C2paError): + """Exception raised for other errors.""" + pass - class Io(Exception): - """Exception raised for IO errors.""" - pass - class Json(Exception): - """Exception raised for JSON errors.""" - pass +class _C2paRemoteManifest(C2paError): + """Exception raised for remote manifest errors.""" + pass - class Manifest(Exception): - """Exception raised for manifest errors.""" - pass - class ManifestNotFound(Exception): - """Exception raised when a manifest is not found.""" - pass +class _C2paResourceNotFound(C2paError): + """Exception raised when a resource is not found.""" + pass - class NotSupported(Exception): - """Exception raised for unsupported operations.""" - pass - class Other(Exception): - """Exception raised for other errors.""" - pass +class _C2paSignature(C2paError): + """Exception raised for signature errors.""" + pass - class RemoteManifest(Exception): - """Exception raised for remote manifest errors.""" - pass - class ResourceNotFound(Exception): - """Exception raised when a resource is not found.""" - pass +class _C2paVerify(C2paError): + """Exception raised for verification errors.""" + pass - class Signature(Exception): - """Exception raised for signature errors.""" - pass - class Verify(Exception): - """Exception raised for verification errors.""" - pass +# Attach exception subclasses to C2paError for backward compatibility +# Preserves behavior for exception catching like except C2paError.ManifestNotFound, +# also reduces imports (think of it as an alias of sorts) +C2paError.Assertion = _C2paAssertion +C2paError.AssertionNotFound = _C2paAssertionNotFound +C2paError.Decoding = _C2paDecoding +C2paError.Encoding = _C2paEncoding +C2paError.FileNotFound = _C2paFileNotFound +C2paError.Io = _C2paIo +C2paError.Json = _C2paJson +C2paError.Manifest = _C2paManifest +C2paError.ManifestNotFound = _C2paManifestNotFound +C2paError.NotSupported = _C2paNotSupported +C2paError.Other = _C2paOther +C2paError.RemoteManifest = _C2paRemoteManifest +C2paError.ResourceNotFound = _C2paResourceNotFound +C2paError.Signature = _C2paSignature +C2paError.Verify = _C2paVerify class _StringContainer: @@ -656,10 +699,83 @@ def _convert_to_py_string(value) -> str: return py_string +def _raise_typed_c2pa_error(error_str: str) -> None: + """Parse an error string and raise the appropriate typed C2paError. + + Error strings from the native library have the format "ErrorType: message". + This function parses the error type and raises the corresponding + C2paError subclass with the full original error string as the message. + + Args: + error_str: The error string from the native library + + Raises: + C2paError subclass: The appropriate typed exception based on error_str + """ + # Error format from native library is "ErrorType: message" or "ErrorType message" + # Try splitting on ": " first (colon-space), then fall back to space only + if ': ' in error_str: + parts = error_str.split(': ', 1) + else: + parts = error_str.split(' ', 1) + if len(parts) > 1: + error_type = parts[0] + # Use the full error string as the message for backward compatibility + if error_type == "Assertion": + raise C2paError.Assertion(error_str) + elif error_type == "AssertionNotFound": + raise C2paError.AssertionNotFound(error_str) + elif error_type == "Decoding": + raise C2paError.Decoding(error_str) + elif error_type == "Encoding": + raise C2paError.Encoding(error_str) + elif error_type == "FileNotFound": + raise C2paError.FileNotFound(error_str) + elif error_type == "Io": + raise C2paError.Io(error_str) + elif error_type == "Json": + raise C2paError.Json(error_str) + elif error_type == "Manifest": + raise C2paError.Manifest(error_str) + elif error_type == "ManifestNotFound": + raise C2paError.ManifestNotFound(error_str) + elif error_type == "NotSupported": + raise C2paError.NotSupported(error_str) + elif error_type == "Other": + raise C2paError.Other(error_str) + elif error_type == "RemoteManifest": + raise C2paError.RemoteManifest(error_str) + elif error_type == "ResourceNotFound": + raise C2paError.ResourceNotFound(error_str) + elif error_type == "Signature": + raise C2paError.Signature(error_str) + elif error_type == "Verify": + raise C2paError.Verify(error_str) + # If no recognized error type, raise base C2paError + raise C2paError(error_str) + + def _parse_operation_result_for_error( result: ctypes.c_void_p | None, check_error: bool = True) -> Optional[str]: - """Helper function to handle string results from C2PA functions.""" + """Helper function to handle string results from C2PA functions. + + When result is falsy and check_error is True, this function retrieves the + error from the native library, parses it, and raises a typed C2paError. + + When result is truthy (a pointer to an error string), this function + converts it to a Python string, parses it, and raises a typed C2paError. + + Args: + result: A pointer to a result string, or None/falsy on error + check_error: Whether to check for errors when result is falsy + + Returns: + None if no error occurred + + Raises: + C2paError subclass: The appropriate typed exception if an error occurred + """ if not result: # pragma: no cover if check_error: error = _lib.c2pa_error() @@ -667,49 +783,22 @@ def _parse_operation_result_for_error( error_str = ctypes.cast( error, ctypes.c_char_p).value.decode('utf-8') _lib.c2pa_string_free(error) - parts = error_str.split(' ', 1) - if len(parts) > 1: - error_type, message = parts - if error_type == "Assertion": - raise C2paError.Assertion(message) - elif error_type == "AssertionNotFound": - raise C2paError.AssertionNotFound(message) - elif error_type == "Decoding": - raise C2paError.Decoding(message) - elif error_type == "Encoding": - raise C2paError.Encoding(message) - elif error_type == "FileNotFound": - raise C2paError.FileNotFound(message) - elif error_type == "Io": - raise C2paError.Io(message) - elif error_type == "Json": - raise C2paError.Json(message) - elif error_type == "Manifest": - raise C2paError.Manifest(message) - elif error_type == "ManifestNotFound": - raise C2paError.ManifestNotFound(message) - elif error_type == "NotSupported": - raise C2paError.NotSupported(message) - elif error_type == "Other": - raise C2paError.Other(message) - elif error_type == "RemoteManifest": - raise C2paError.RemoteManifest(message) - elif error_type == "ResourceNotFound": - raise C2paError.ResourceNotFound(message) - elif error_type == "Signature": - raise C2paError.Signature(message) - elif error_type == "Verify": - raise C2paError.Verify(message) - return error_str + _raise_typed_c2pa_error(error_str) return None # In the case result would be a string already (error message) - return _convert_to_py_string(result) + error_str = _convert_to_py_string(result) + if error_str: + _raise_typed_c2pa_error(error_str) + return None def sdk_version() -> str: """ Returns the underlying c2pa-rs/c2pa-c-ffi version string + c2pa-rs and c2pa-c-ffi versions are in lockstep release, + so the version string is the same for both and we return + the shared semantic version number. """ vstr = version() # Example: "c2pa-c/0.60.1 c2pa-rs/0.60.1" @@ -721,7 +810,11 @@ def sdk_version() -> str: def version() -> str: - """Get the C2PA library version.""" + """ + Get the C2PA library version with the fully qualified names + of the native core libraries (library names and semantic version + numbers). + """ result = _lib.c2pa_version() return _convert_to_py_string(result) @@ -2622,7 +2715,7 @@ def set_intent( - EDIT: Edit of a pre-existing parent asset. Must have a parent ingredient. - UPDATE: Restricted version of Edit for non-editorial changes. - Must have only one ingredient as a parent. + Must have only one ingredient, as a parent. Args: intent: The builder intent (C2paBuilderIntent enum value) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 81389ce8..6215adbc 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -85,6 +85,14 @@ def test_can_retrieve_reader_supported_mimetypes(self): self.assertEqual(result1, result2) + def test_stream_read_nothing_to_read(self): + # The ingredient test file has no manifest + # So if we instantiate directly, the Reader instance should throw + with open(INGREDIENT_TEST_FILE, "rb") as file: + with self.assertRaises(Error) as context: + reader = Reader("image/jpeg", file) + self.assertIn("ManifestNotFound: no JUMBF data found", str(context.exception)) + def test_stream_read(self): with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file)