From fbf11adcb2a24d6674ebdfcc802bc61c67fc0595 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 09:36:34 -0800 Subject: [PATCH 01/20] fix: Clarify version docs --- src/c2pa/c2pa.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 82cad35c..1a242f47 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. @@ -710,6 +706,8 @@ def _parse_operation_result_for_error( 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. """ vstr = version() # Example: "c2pa-c/0.60.1 c2pa-rs/0.60.1" @@ -721,7 +719,10 @@ def sdk_version() -> str: def version() -> str: - """Get the C2PA library version.""" + """ + Get the C2PA library version with the fully qualified name + of the native core libraries. + """ result = _lib.c2pa_version() return _convert_to_py_string(result) From cb9ad45251465832890304b093ead5ae01852527 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 09:52:12 -0800 Subject: [PATCH 02/20] fix: Add the test that verifies direct instance throws --- src/c2pa/c2pa.py | 4 ++++ tests/test_unit_tests.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 1a242f47..ce7390b5 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1359,6 +1359,10 @@ class Reader: 'closed_error': "Reader is closed" } + @classmethod + def get_reader(): + pass + @classmethod def get_supported_mime_types(cls) -> list[str]: """Get the list of supported MIME types for the Reader. 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) From f8c60db1979aa5660d0db7f382fe0de9e504d5f3 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 10:26:54 -0800 Subject: [PATCH 03/20] fix: Add the tests for the factory method --- src/c2pa/c2pa.py | 33 +++++++++++++++++++++++++++++++-- tests/test_unit_tests.py | 15 +++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index ce7390b5..1ce0da8c 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1360,8 +1360,37 @@ class Reader: } @classmethod - def get_reader(): - pass + def from_asset(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 found (instead of raising a + ManifestNotFound: no JUMBF data found exception). + + That method handles the case where you want to try to read C2PA data + from an asset that may or may not contain a manifest. As such, this methods + takes the same parameters as the Reader constructor __init__ method. + + 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 + + Raises: + C2paError: If there was an error other than "no manifest found" + """ + try: + return cls(format_or_path, stream, manifest_data) + except C2paError as e: + if "ManifestNotFound" in str(e) or "no JUMBF data found" in str(e): + # Nothing to read, so no Reader returned + return None + # Any other error that may happen will still raise an exception + raise @classmethod def get_supported_mime_types(cls) -> list[str]: diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 6215adbc..f7ead29b 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -93,12 +93,27 @@ def test_stream_read_nothing_to_read(self): reader = Reader("image/jpeg", file) self.assertIn("ManifestNotFound: no JUMBF data found", str(context.exception)) + def test_from_asset_reader_nothing_to_read(self): + # The ingredient test file has no manifest + # So if we use Reader.from_asset, in this case we'll get None + # And no error should be raised + with open(INGREDIENT_TEST_FILE, "rb") as file: + reader = Reader.from_asset("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_from_asset_reader_from_stream(self): + with open(self.testPath, "rb") as file: + reader = Reader.from_asset("image/jpeg", file) + self.assertIsNotNone(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) From 6f0085058b2cc99d042233f0d82d98b498f9c9e9 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 11:12:47 -0800 Subject: [PATCH 04/20] fix: Add tests for from_asset --- tests/test_unit_tests.py | 126 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index f7ead29b..2ea82166 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -252,10 +252,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_from_asset_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.from_asset(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_from_asset_raises_mimetype_not_recognized(self): + with self.assertRaises(Error.NotSupported): + Reader.from_asset(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() @@ -332,6 +342,75 @@ def test_read_dng_file_from_path(self): # Just run and verify there is no crash json.loads(reader.json()) + def test_from_asset_from_path(self): + test_path = os.path.join(self.data_dir, "C.dng") + + # Create reader with the file content + reader = Reader.from_asset(test_path) + self.assertIsNotNone(reader) + # Just run and verify there is no crash + json.loads(reader.json()) + + def test_from_asset_all_files(self): + """Test reading C2PA metadata using Reader.from_asset 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.from_asset(mime_type, file) + # from_asset 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") @@ -390,6 +469,53 @@ def test_read_all_files(self): except Exception as e: self.fail(f"Failed to read metadata from {filename}: {str(e)}") + def test_from_asset_all_files_using_extension(self): + """Test reading C2PA metadata using Reader.from_asset 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.from_asset(parsed_extension, file) + # from_asset 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") From bc33df6facaeebe1c6435e733123dd7b52bea5cf Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 11:23:47 -0800 Subject: [PATCH 05/20] fix: Add tests --- src/c2pa/c2pa.py | 5 +++-- tests/test_unit_tests.py | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 1ce0da8c..13c015bc 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1378,10 +1378,11 @@ def from_asset(cls, manifest_data: Optional manifest data in bytes Returns: - Reader instance if the asset contains C2PA data, None if no manifest found + 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 "no manifest found" + C2paError: If there was an error other than "ManifestNotFound" """ try: return cls(format_or_path, stream, manifest_data) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 2ea82166..2a98f404 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -114,6 +114,17 @@ def test_from_asset_reader_from_stream(self): json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + def test_from_asset_reader_from_stream_context_manager(self): + with open(self.testPath, "rb") as file: + reader = Reader.from_asset("image/jpeg", file) + self.assertIsNotNone(reader) + # Check that a Reader returned by from_asset 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) @@ -470,7 +481,10 @@ def test_read_all_files(self): self.fail(f"Failed to read metadata from {filename}: {str(e)}") def test_from_asset_all_files_using_extension(self): - """Test reading C2PA metadata using Reader.from_asset from files in the fixtures/files-for-reading-tests directory""" + """ + Test reading C2PA metadata using Reader.from_asset + 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 From 8c0994ca39e10d90f17bd002a61e4c9aeea9af06 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 11:24:52 -0800 Subject: [PATCH 06/20] fix: Typos in docs --- src/c2pa/c2pa.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 13c015bc..ba8d9fef 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1364,12 +1364,12 @@ def from_asset(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, + """This is a factory-like method to create a new Reader from an asset, returning None if no manifest found (instead of raising a ManifestNotFound: no JUMBF data found exception). That method handles the case where you want to try to read C2PA data - from an asset that may or may not contain a manifest. As such, this methods + from an asset that may or may not contain a manifest. As such, this method takes the same parameters as the Reader constructor __init__ method. Args: From 6bc2d74ad11825c30a95c939372e366d7eff1c71 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 11:26:50 -0800 Subject: [PATCH 07/20] fix: Add docs --- src/c2pa/c2pa.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index ba8d9fef..2fdb4b7d 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -707,7 +707,8 @@ 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. + 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" @@ -720,8 +721,9 @@ def sdk_version() -> str: def version() -> str: """ - Get the C2PA library version with the fully qualified name - of the native core libraries. + 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) From 79b7eb8142827cd82379eaccfc4fdb1e20d175e9 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 11:29:55 -0800 Subject: [PATCH 08/20] fix: Docs --- src/c2pa/c2pa.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 2fdb4b7d..984fba91 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1367,12 +1367,15 @@ def from_asset(cls, stream: Optional[Any] = None, manifest_data: Optional[Any] = None) -> Optional["Reader"]: """This is a factory-like method to create a new Reader from an asset, - returning None if no manifest found (instead of raising a - ManifestNotFound: no JUMBF data found exception). + returning None if no manifest/c2pa data/JUMBF data could be read + (instead of raising a ManifestNotFound: no JUMBF data found exception). That method handles the case where you want to try to read C2PA data - from an asset that may or may not contain a manifest. As such, this method - takes the same parameters as the Reader constructor __init__ method. + from an asset that may or may not contain a c2pa manifest. As such, + this method takes the same parameters as the Reader constructor + __init__ method to attempt to read c2pa data from the asset, but returns + None if no c2pa manifest data could be read instead of throwing + an error. Args: format_or_path: The format or path to read from @@ -1387,6 +1390,7 @@ def from_asset(cls, 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 as e: if "ManifestNotFound" in str(e) or "no JUMBF data found" in str(e): From 6b72e986866428387a0c27d20aa09ef4e54366bf Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 11:41:15 -0800 Subject: [PATCH 09/20] fix: Typo --- src/c2pa/c2pa.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 984fba91..3c743cda 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -2663,7 +2663,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) From 6edf6ef8f7b01d04fc883b0c527279a055af4a7a Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 14:55:11 -0800 Subject: [PATCH 10/20] fix: Rename method --- src/c2pa/c2pa.py | 4 ++-- tests/test_unit_tests.py | 44 ++++++++++++++++++++-------------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 3c743cda..6c23f4d0 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1362,11 +1362,11 @@ class Reader: } @classmethod - def from_asset(cls, + 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-like method to create a new Reader from an asset, + """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). diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 2a98f404..ac6fdbe3 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -93,12 +93,12 @@ def test_stream_read_nothing_to_read(self): reader = Reader("image/jpeg", file) self.assertIn("ManifestNotFound: no JUMBF data found", str(context.exception)) - def test_from_asset_reader_nothing_to_read(self): + def test_try_create_reader_nothing_to_read(self): # The ingredient test file has no manifest - # So if we use Reader.from_asset, in this case we'll get None + # 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.from_asset("image/jpeg", file) + reader = Reader.try_create("image/jpeg", file) self.assertIsNone(reader) def test_stream_read(self): @@ -107,18 +107,18 @@ def test_stream_read(self): json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) - def test_from_asset_reader_from_stream(self): + def test_try_create_reader_from_stream(self): with open(self.testPath, "rb") as file: - reader = Reader.from_asset("image/jpeg", 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_from_asset_reader_from_stream_context_manager(self): + def test_try_create_reader_from_stream_context_manager(self): with open(self.testPath, "rb") as file: - reader = Reader.from_asset("image/jpeg", file) + reader = Reader.try_create("image/jpeg", file) self.assertIsNotNone(reader) - # Check that a Reader returned by from_asset is not None, + # 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: @@ -263,19 +263,19 @@ 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_from_asset_raises_mimetype_not_supported(self): + 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.from_asset(os.path.join(FIXTURES_DIR, "C.xyz")) + 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_from_asset_raises_mimetype_not_recognized(self): + def test_try_create_raises_mimetype_not_recognized(self): with self.assertRaises(Error.NotSupported): - Reader.from_asset(os.path.join(FIXTURES_DIR, "C.test")) + 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: @@ -353,17 +353,17 @@ def test_read_dng_file_from_path(self): # Just run and verify there is no crash json.loads(reader.json()) - def test_from_asset_from_path(self): + 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.from_asset(test_path) + reader = Reader.try_create(test_path) self.assertIsNotNone(reader) # Just run and verify there is no crash json.loads(reader.json()) - def test_from_asset_all_files(self): - """Test reading C2PA metadata using Reader.from_asset from all files in the fixtures/files-for-reading-tests directory""" + 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 @@ -409,8 +409,8 @@ def test_from_asset_all_files(self): try: with open(file_path, "rb") as file: - reader = Reader.from_asset(mime_type, file) - # from_asset returns None if no manifest found, otherwise a Reader + 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() @@ -480,9 +480,9 @@ def test_read_all_files(self): except Exception as e: self.fail(f"Failed to read metadata from {filename}: {str(e)}") - def test_from_asset_all_files_using_extension(self): + def test_try_create_all_files_using_extension(self): """ - Test reading C2PA metadata using Reader.from_asset + 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") @@ -517,8 +517,8 @@ def test_from_asset_all_files_using_extension(self): with open(file_path, "rb") as file: # Remove the leading dot parsed_extension = ext[1:] - reader = Reader.from_asset(parsed_extension, file) - # from_asset returns None if no manifest found, otherwise a Reader + 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() From 7fe1f88dec3c5f60b1cb68f18cc94e74b6742d64 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 15:13:57 -0800 Subject: [PATCH 11/20] fix: Exception classes hierarchy --- src/c2pa/c2pa.py | 272 ++++++++++++++++++++++++++++++----------------- 1 file changed, 177 insertions(+), 95 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 6c23f4d0..c76ac29f 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -533,71 +533,114 @@ 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 properly inherit from C2paError +# These are attached to C2paError as class attributes for backward compatibility +# (e.g., C2paError.ManifestNotFound) + +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 Decoding(Exception): - """Exception raised for decoding errors.""" - pass - class Encoding(Exception): - """Exception raised for encoding errors.""" - pass +class _C2paIo(C2paError): + """Exception raised for IO errors.""" + pass - class FileNotFound(Exception): - """Exception raised when a file is not found.""" - pass - class Io(Exception): - """Exception raised for IO errors.""" - pass +class _C2paJson(C2paError): + """Exception raised for JSON errors.""" + pass - class Json(Exception): - """Exception raised for JSON errors.""" - pass - class Manifest(Exception): - """Exception raised for manifest errors.""" - pass +class _C2paManifest(C2paError): + """Exception raised for manifest errors.""" + pass - class ManifestNotFound(Exception): - """Exception raised when a manifest is not found.""" - pass - class NotSupported(Exception): - """Exception raised for unsupported operations.""" - pass +class _C2paManifestNotFound(C2paError): + """Exception raised when a manifest is not found.""" + pass - class Other(Exception): - """Exception raised for other errors.""" - pass - class RemoteManifest(Exception): - """Exception raised for remote manifest errors.""" - pass +class _C2paNotSupported(C2paError): + """Exception raised for unsupported operations.""" + pass - class ResourceNotFound(Exception): - """Exception raised when a resource is not found.""" - pass - class Signature(Exception): - """Exception raised for signature errors.""" - pass +class _C2paOther(C2paError): + """Exception raised for other errors.""" + pass - class Verify(Exception): - """Exception raised for verification errors.""" - pass + +class _C2paRemoteManifest(C2paError): + """Exception raised for remote manifest errors.""" + pass + + +class _C2paResourceNotFound(C2paError): + """Exception raised when a resource is not found.""" + pass + + +class _C2paSignature(C2paError): + """Exception raised for signature errors.""" + pass + + +class _C2paVerify(C2paError): + """Exception raised for verification errors.""" + pass + + +# Attach exception subclasses to C2paError for backward compatibility +# Preservers behavio 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: @@ -652,10 +695,84 @@ 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 + for backward compatibility. + + 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() @@ -663,44 +780,14 @@ 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: @@ -1370,12 +1457,10 @@ def try_create(cls, returning None if no manifest/c2pa data/JUMBF data could be read (instead of raising a ManifestNotFound: no JUMBF data found exception). - That method handles the case where you want to try to read C2PA data - from an asset that may or may not contain a c2pa manifest. As such, - this method takes the same parameters as the Reader constructor - __init__ method to attempt to read c2pa data from the asset, but returns - None if no c2pa manifest data could be read instead of throwing - an error. + 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 @@ -1387,17 +1472,14 @@ def try_create(cls, None if no manifest found (ManifestNotFound: no JUMBF data found) Raises: - C2paError: If there was an error other than "ManifestNotFound" + 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 as e: - if "ManifestNotFound" in str(e) or "no JUMBF data found" in str(e): - # Nothing to read, so no Reader returned - return None - # Any other error that may happen will still raise an exception - raise + except C2paError.ManifestNotFound: + # Nothing to read, so no Reader returned + return None @classmethod def get_supported_mime_types(cls) -> list[str]: From 1f3999da4078bd4394348f4893edbd7603ffc90c Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 15:17:16 -0800 Subject: [PATCH 12/20] fix: COmment typos... --- src/c2pa/c2pa.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index c76ac29f..09641245 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -544,9 +544,9 @@ def __init__(self, message: str = ""): super().__init__(message) -# Define typed exception subclasses that properly inherit from C2paError +# Define typed exception subclasses that inherit from C2paError # These are attached to C2paError as class attributes for backward compatibility -# (e.g., C2paError.ManifestNotFound) +# (eg., C2paError.ManifestNotFound), and also to ensure properly inheritance hierarchy class _C2paAssertion(C2paError): """Exception raised for assertion errors.""" @@ -589,7 +589,11 @@ class _C2paManifest(C2paError): class _C2paManifestNotFound(C2paError): - """Exception raised when a manifest is not found.""" + """ + 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 @@ -624,7 +628,7 @@ class _C2paVerify(C2paError): # Attach exception subclasses to C2paError for backward compatibility -# Preservers behavio for exception catching like except C2paError.ManifestNotFound, +# 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 From f458e6eb986301d461b475a8ee922ce58fd96619 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 15:20:18 -0800 Subject: [PATCH 13/20] fix: Typos in comments --- src/c2pa/c2pa.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 09641245..a14513ac 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -704,8 +704,7 @@ def _raise_typed_c2pa_error(error_str: str) -> None: 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 - for backward compatibility. + C2paError subclass with the full original error string as the message. Args: error_str: The error string from the native library From 751ce103993466ab6d0ab3bf2cb2bcfbdb471e63 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 15:35:23 -0800 Subject: [PATCH 14/20] fix: In examples, add link to the app repo example --- examples/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index ce8003b9..ff1cec78 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 ``` + +## Full-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. From fef71d15577b52e3e661b1082b124c0c1fd8b0f4 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 15:35:54 -0800 Subject: [PATCH 15/20] fix: In examples, add link to the app repo example --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index ff1cec78..866b55d1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -97,6 +97,6 @@ In this example, `SignerInfo` creates a `Signer` object that signs the manifest. python examples/sign_info.py ``` -## Full-application example +## Full 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. From 2cfb7b8825993b9e70f47192d1ba34c2a30e9df1 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 15:37:48 -0800 Subject: [PATCH 16/20] fix: In examples, add link to the app repo example --- examples/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/README.md b/examples/README.md index 866b55d1..027da526 100644 --- a/examples/README.md +++ b/examples/README.md @@ -97,6 +97,6 @@ In this example, `SignerInfo` creates a `Signer` object that signs the manifest. python examples/sign_info.py ``` -## Full application example +## 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. +[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. From 64f00120daeca6e0f02fbd118e4afe417804540a Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 15:41:37 -0800 Subject: [PATCH 17/20] fix: Add docs link --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d33472ee..96b64a2b 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 [the section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation). ## Contributing From acb968f060054cf82d4269fa9c43e5e08370592d Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 15:43:49 -0800 Subject: [PATCH 18/20] fix: Add docs link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 96b64a2b..c2d20851 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/m 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 [the section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation). +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 From be91456fb38c2a6f9b5e91713a5017d355dc5c2f Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 6 Jan 2026 20:03:29 -0800 Subject: [PATCH 19/20] fix: Clean up exception handling --- src/c2pa/c2pa.py | 33 --------- tests/test_unit_tests.py | 155 --------------------------------------- 2 files changed, 188 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index a14513ac..7f2f4325 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1451,39 +1451,6 @@ 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. diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index ac6fdbe3..6215adbc 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -93,38 +93,12 @@ def test_stream_read_nothing_to_read(self): 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) @@ -263,20 +237,10 @@ 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() @@ -353,75 +317,6 @@ 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") @@ -480,56 +375,6 @@ 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") From 2865544a56e5a5d029b8ea88d31496b68fe3a3c7 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Wed, 7 Jan 2026 11:54:57 -0800 Subject: [PATCH 20/20] fix: try_read --- src/c2pa/c2pa.py | 33 +++++++++ tests/test_unit_tests.py | 155 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 7f2f4325..02980dc9 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1514,6 +1514,39 @@ def get_supported_mime_types(cls) -> list[str]: return cls._supported_mime_types_cache + @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, + 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 + def __init__(self, format_or_path: Union[str, Path], diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 6215adbc..75b3fee1 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -93,12 +93,38 @@ def test_stream_read_nothing_to_read(self): 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) @@ -231,16 +257,35 @@ def test_stream_read_string_stream(self): json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + 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_stream_read_string_stream_mimetype_not_supported(self): with self.assertRaises(Error.NotSupported): # xyz is actually an extension that is recognized # 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() @@ -375,6 +420,116 @@ 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(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_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")