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..a14513ac 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 Decoding(Exception): - """Exception raised for decoding errors.""" - pass +class _C2paJson(C2paError): + """Exception raised for JSON errors.""" + pass - class Encoding(Exception): - """Exception raised for encoding errors.""" - pass - class FileNotFound(Exception): - """Exception raised when a file is not found.""" - pass +class _C2paManifest(C2paError): + """Exception raised for manifest errors.""" + pass - class Io(Exception): - """Exception raised for IO errors.""" - pass - class Json(Exception): - """Exception raised for JSON 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 _C2paNotSupported(C2paError): + """Exception raised for unsupported operations.""" + pass + + +class _C2paOther(C2paError): + """Exception raised for other 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) @@ -1358,6 +1451,39 @@ class Reader: 'closed_error': "Reader is closed" } + @classmethod + def try_create(cls, + format_or_path: Union[str, Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None) -> Optional["Reader"]: + """This is a factory method to create a new Reader from an asset, + returning None if no manifest/c2pa data/JUMBF data could be read + (instead of raising a ManifestNotFound: no JUMBF data found exception). + + Returns None instead of raising C2paError.ManifestNotFound if no + C2PA manifest data is found in the asset. This is useful when you + want to check if an asset contains C2PA data without handling + exceptions for the expected case of no manifest. + + Args: + format_or_path: The format or path to read from + stream: Optional stream to read from (Python stream-like object) + manifest_data: Optional manifest data in bytes + + Returns: + Reader instance if the asset contains C2PA data, + None if no manifest found (ManifestNotFound: no JUMBF data found) + + Raises: + C2paError: If there was an error other than ManifestNotFound + """ + try: + # Reader creations checks deferred to the constructor __init__ method + return cls(format_or_path, stream, manifest_data) + except C2paError.ManifestNotFound: + # Nothing to read, so no Reader returned + return None + @classmethod def get_supported_mime_types(cls) -> list[str]: """Get the list of supported MIME types for the Reader. @@ -2622,7 +2748,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..ac6fdbe3 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -85,12 +85,46 @@ 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_try_create_reader_nothing_to_read(self): + # The ingredient test file has no manifest + # So if we use Reader.try_create, in this case we'll get None + # And no error should be raised + with open(INGREDIENT_TEST_FILE, "rb") as file: + reader = Reader.try_create("image/jpeg", file) + self.assertIsNone(reader) + def test_stream_read(self): with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + def test_try_create_reader_from_stream(self): + with open(self.testPath, "rb") as file: + reader = Reader.try_create("image/jpeg", file) + self.assertIsNotNone(reader) + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_try_create_reader_from_stream_context_manager(self): + with open(self.testPath, "rb") as file: + reader = Reader.try_create("image/jpeg", file) + self.assertIsNotNone(reader) + # Check that a Reader returned by try_create is not None, + # before using it in a context manager pattern (with) + if reader is not None: + with reader: + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + def test_stream_read_detailed(self): with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) @@ -229,10 +263,20 @@ def test_stream_read_string_stream_mimetype_not_supported(self): # as mimetype chemical/x-xyz Reader(os.path.join(FIXTURES_DIR, "C.xyz")) + def test_try_create_raises_mimetype_not_supported(self): + with self.assertRaises(Error.NotSupported): + # xyz is actually an extension that is recognized + # as mimetype chemical/x-xyz, but we don't support it + Reader.try_create(os.path.join(FIXTURES_DIR, "C.xyz")) + def test_stream_read_string_stream_mimetype_not_recognized(self): with self.assertRaises(Error.NotSupported): Reader(os.path.join(FIXTURES_DIR, "C.test")) + def test_try_create_raises_mimetype_not_recognized(self): + with self.assertRaises(Error.NotSupported): + Reader.try_create(os.path.join(FIXTURES_DIR, "C.test")) + def test_stream_read_string_stream(self): with Reader("image/jpeg", self.testPath) as reader: json_data = reader.json() @@ -309,6 +353,75 @@ def test_read_dng_file_from_path(self): # Just run and verify there is no crash json.loads(reader.json()) + def test_try_create_from_path(self): + test_path = os.path.join(self.data_dir, "C.dng") + + # Create reader with the file content + reader = Reader.try_create(test_path) + self.assertIsNotNone(reader) + # Just run and verify there is no crash + json.loads(reader.json()) + + def test_try_create_all_files(self): + """Test reading C2PA metadata using Reader.try_create from all files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + reader = Reader.try_create(mime_type, file) + # try_create returns None if no manifest found, otherwise a Reader + self.assertIsNotNone(reader, f"Expected Reader for {filename}") + json_data = reader.json() + reader.close() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + def test_read_all_files(self): """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory""" reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") @@ -367,6 +480,56 @@ def test_read_all_files(self): except Exception as e: self.fail(f"Failed to read metadata from {filename}: {str(e)}") + def test_try_create_all_files_using_extension(self): + """ + Test reading C2PA metadata using Reader.try_create + from files in the fixtures/files-for-reading-tests directory + """ + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + extensions = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in extensions: + continue + + try: + with open(file_path, "rb") as file: + # Remove the leading dot + parsed_extension = ext[1:] + reader = Reader.try_create(parsed_extension, file) + # try_create returns None if no manifest found, otherwise a Reader + self.assertIsNotNone(reader, f"Expected Reader for {filename}") + json_data = reader.json() + reader.close() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + def test_read_all_files_using_extension(self): """Test reading C2PA metadata from files in the fixtures/files-for-reading-tests directory""" reading_dir = os.path.join(self.data_dir, "files-for-reading-tests")