From 4c1ea4a360b60ff19cb4d69599ed83581efab5f3 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 2 Oct 2025 20:29:44 -0700 Subject: [PATCH 01/17] fix: Add some more APIs --- src/c2pa/c2pa.py | 51 +++++++++++++++++++++++ tests/test_unit_tests.py | 88 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 1ab70ec0..818bdfb1 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -64,6 +64,8 @@ 'c2pa_free_string_array', 'c2pa_reader_supported_mime_types', 'c2pa_builder_supported_mime_types', + 'c2pa_reader_is_embedded', + 'c2pa_reader_remote_url', ] # TODO Bindings: @@ -369,6 +371,16 @@ def _setup_function(func, argtypes, restype=None): [ctypes.POINTER(ctypes.c_size_t)], ctypes.POINTER(ctypes.c_char_p) ) +_setup_function( + _lib.c2pa_reader_is_embedded, + [ctypes.POINTER(C2paReader)], + ctypes.c_bool +) +_setup_function( + _lib.c2pa_reader_remote_url, + [ctypes.POINTER(C2paReader)], + ctypes.c_void_p +) # Set up Builder function prototypes _setup_function( @@ -1662,6 +1674,45 @@ def resource_to_stream(self, uri: str, stream: Any) -> int: return result + def is_embedded(self) -> bool: + """Check if the reader was created from an embedded manifest. + This method determines whether the C2PA manifest is embedded directly + in the asset file or stored remotely. + Returns: + True if the reader was created from an embedded manifest, + False if it was created from a remote manifest + Raises: + C2paError: If there was an error checking the embedded status + """ + self._ensure_valid_state() + + result = _lib.c2pa_reader_is_embedded(self._reader) + + # c_bool should return a Python bool directly + return bool(result) + + def get_remote_url(self) -> Optional[str]: + """Get the remote URL of the manifest if it was obtained remotely. + This method returns the URL from which the C2PA manifest was fetched + if the reader was created from a remote manifest. If the manifest + is embedded in the asset, this will return None. + Returns: + The remote URL as a string if the manifest was obtained remotely, + None if the manifest is embedded or no remote URL is available + Raises: + C2paError: If there was an error getting the remote URL + """ + self._ensure_valid_state() + + result = _lib.c2pa_reader_remote_url(self._reader) + + if result is None: + # No remote URL set (manifest is embedded) + return None + + # Convert the C string to Python string + url_str = _convert_to_py_string(result) + return url_str class Signer: """High-level wrapper for C2PA Signer operations.""" diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 51fc0ea9..98aa4ae8 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -342,6 +342,94 @@ def test_reader_state_with_invalid_native_pointer(self): with self.assertRaises(Error): reader.json() + def test_reader_is_embedded(self): + """Test the is_embedded method returns correct values for embedded and remote manifests.""" + + # Test with a fixture which has an embedded manifest + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + self.assertTrue(reader.is_embedded()) + reader.close() + + # Test with cloud.jpg fixture which has a remote manifest (not embedded) + cloud_fixture_path = os.path.join(self.data_dir, "cloud.jpg") + with Reader("image/jpeg", cloud_fixture_path) as reader: + self.assertFalse(reader.is_embedded()) + + def test_sign_and_read_is_not_embedded(self): + """Test the is_embedded method returns correct values for embedded and remote manifests.""" + + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + key = key_file.read() + + # Create signer info and signer + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + # Define a simple manifest + manifest_definition = { + "claim_generator": "python_test", + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened" + } + ] + } + } + ] + } + + # Create a temporary directory for the signed file + with tempfile.TemporaryDirectory() as temp_dir: + temp_file_path = os.path.join(temp_dir, "signed_test_file_no_embed.jpg") + + with open(self.testPath, "rb") as file: + builder = Builder(manifest_definition) + # Direct the Builder not to embed the manifest into the asset + builder.set_no_embed() + + + with open(temp_file_path, "wb") as temp_file: + manifest_data = builder.sign( + signer, "image/jpeg", file, temp_file) + + with Reader("image/jpeg", temp_file_path, manifest_data) as reader: + self.assertFalse(reader.is_embedded()) + + def test_reader_get_remote_url(self): + """Test the get_remote_url method returns correct values for embedded and remote manifests.""" + + # Test get_remote_url for file with embedded manifest (should return None) + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + self.assertIsNone(reader.get_remote_url()) + reader.close() + + # Test remote manifest using cloud.jpg fixture which has a remote URL + cloud_fixture_path = os.path.join(self.data_dir, "cloud.jpg") + with Reader("image/jpeg", cloud_fixture_path) as reader: + remote_url = reader.get_remote_url() + self.assertEqual(remote_url, "https://cai-manifests.adobe.com/manifests/adobe-urn-uuid-5f37e182-3687-462e-a7fb-573462780391") + self.assertFalse(reader.is_embedded()) + # TODO: Unskip once fixed configuration to read data is clarified # def test_read_cawg_data_file(self): # """Test reading C2PA metadata from C_with_CAWG_data.jpg file.""" From 933a6dd7b3264230b6d1b773a7c1b4b0a6adddb5 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 2 Oct 2025 20:30:36 -0700 Subject: [PATCH 02/17] fix: Add some more APIs 2 --- tests/test_unit_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 98aa4ae8..6904ca86 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -357,7 +357,7 @@ def test_reader_is_embedded(self): self.assertFalse(reader.is_embedded()) def test_sign_and_read_is_not_embedded(self): - """Test the is_embedded method returns correct values for embedded and remote manifests.""" + """Test the is_embedded method returns correct values for remote manifests.""" with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: certs = cert_file.read() From 2ae3ccfca82466973ff8a67daaf09f11e6820be3 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 2 Oct 2025 20:56:20 -0700 Subject: [PATCH 03/17] fix: Active manifest API --- src/c2pa/c2pa.py | 53 ++++++++++++++++++++++++++++++++++++++++ tests/test_unit_tests.py | 9 +++++++ 2 files changed, 62 insertions(+) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 818bdfb1..00bb4ad0 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1246,6 +1246,7 @@ class Reader: ``` with Reader("image/jpeg", output) as reader: manifest_json = reader.json() + active_manifest = reader.get_active_manifest() ``` Where `output` is either an in-memory stream or an opened file. """ @@ -1644,6 +1645,58 @@ def json(self) -> str: return _convert_to_py_string(result) + def get_active_manifest(self) -> dict: + """Get the active manifest from the manifest store. + + This method retrieves the full manifest JSON and extracts the active + manifest based on the active_manifest key. + + Returns: + A dictionary containing the active manifest data, including claims, + assertions, ingredients, and signature information. + + Raises: + C2paError: If there was an error getting the manifest JSON + KeyError: If the active_manifest key is missing from the JSON + ValueError: If the active manifest ID is not found in the manifests + + Example: + ```python + with Reader("image/jpeg", file) as reader: + active_manifest = reader.get_active_manifest() + print(f"Title: {active_manifest['title']}") + print(f"Format: {active_manifest['format']}") + for assertion in active_manifest['assertions']: + print(f"Assertion: {assertion['label']}") + ``` + """ + # Get the full manifest JSON + manifest_json_str = self.json() + + try: + # Parse the JSON + manifest_data = json.loads(manifest_json_str) + except json.JSONDecodeError as e: + raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e + + # Extract the active manifest ID + if "active_manifest" not in manifest_data: + raise KeyError("No 'active_manifest' key found in manifest data") + + active_manifest_id = manifest_data["active_manifest"] + + # Get the manifests object to retrieve the active manifest from there + if "manifests" not in manifest_data: + raise KeyError("No 'manifests' key found in manifest data") + + manifests = manifest_data["manifests"] + + # Look up the active manifest + if active_manifest_id not in manifests: + raise ValueError(f"Active manifest ID '{active_manifest_id}' not found in manifests") + + return manifests[active_manifest_id] + def resource_to_stream(self, uri: str, stream: Any) -> int: """Write a resource to a stream. diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 6904ca86..802fe7eb 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -64,6 +64,15 @@ def test_stream_read(self): json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + def test_stream_read_get_active_manifest(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + active_manifest = reader.get_active_manifest() + + # Check the returned manifest label/key + expected_label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" + self.assertEqual(active_manifest["label"], expected_label) + def test_reader_detects_unsupported_mimetype_on_stream(self): with open(self.testPath, "rb") as file: with self.assertRaises(Error.NotSupported): From f6d8bfdc0d132fe2d5deae35da50856e0b51c6f7 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 2 Oct 2025 21:16:50 -0700 Subject: [PATCH 04/17] fix: Convenience APIs --- src/c2pa/c2pa.py | 54 +++++++++++++++++++++++++++++++++++++++- tests/test_unit_tests.py | 20 +++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 00bb4ad0..7ba99ab2 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1247,6 +1247,8 @@ class Reader: with Reader("image/jpeg", output) as reader: manifest_json = reader.json() active_manifest = reader.get_active_manifest() + validation_state = reader.get_validation_state() + validation_results = reader.validation_results() ``` Where `output` is either an in-memory stream or an opened file. """ @@ -1693,10 +1695,59 @@ def get_active_manifest(self) -> dict: # Look up the active manifest if active_manifest_id not in manifests: - raise ValueError(f"Active manifest ID '{active_manifest_id}' not found in manifests") + raise ValueError("Active manifest idnot found in manifests") return manifests[active_manifest_id] + def get_validation_state(self) -> Optional[str]: + """Get the validation state of the manifest store. + + This method retrieves the full manifest JSON and extracts the + validation_state field, which indicates the overall validation + status of the C2PA manifest. + + Returns: + The validation state as a string, + or None if the validation_state field is not present. + + Raises: + C2paError: If there was an error getting the manifest JSON + """ + manifest_json_str = self.json() + try: + # Parse the JSON + manifest_data = json.loads(manifest_json_str) + except json.JSONDecodeError as e: + raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e + + # Extract the validation_state field + return manifest_data.get("validation_state") + + def get_validation_results(self) -> Optional[dict]: + """Get the validation results of the manifest store. + + This method retrieves the full manifest JSON and extracts + the validation_results object, which contains detailed + validation information. + + Returns: + The validation results as a dictionary containing + validation details, or None if the validation_results + field is not present. + + Raises: + C2paError: If there was an error getting the manifest JSON + """ + manifest_json_str = self.json() + try: + # Parse the JSON + manifest_data = json.loads(manifest_json_str) + except json.JSONDecodeError as e: + raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e + + # Extract the validation_results field + return manifest_data.get("validation_results") + def resource_to_stream(self, uri: str, stream: Any) -> int: """Write a resource to a stream. @@ -1767,6 +1818,7 @@ def get_remote_url(self) -> Optional[str]: url_str = _convert_to_py_string(result) return url_str + class Signer: """High-level wrapper for C2PA Signer operations.""" diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 802fe7eb..6eb40644 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -73,6 +73,26 @@ def test_stream_read_get_active_manifest(self): expected_label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" self.assertEqual(active_manifest["label"], expected_label) + def test_stream_read_get_validation_state(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + validation_state = reader.get_validation_state() + self.assertIsNotNone(validation_state) + self.assertEqual(validation_state, "Valid") + + def test_stream_read_get_validation_results(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + validation_results = reader.get_validation_results() + + self.assertIsNotNone(validation_results) + self.assertIsInstance(validation_results, dict) + + # Verify some active manifest validation results + self.assertIn("activeManifest", validation_results) + active_manifest_results = validation_results["activeManifest"] + self.assertIsInstance(active_manifest_results, dict) + def test_reader_detects_unsupported_mimetype_on_stream(self): with open(self.testPath, "rb") as file: with self.assertRaises(Error.NotSupported): From 9f493de88448a66110a4dbd91e166f2b8f4caa4b Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 2 Oct 2025 21:45:20 -0700 Subject: [PATCH 05/17] fix: Clean up --- src/c2pa/c2pa.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 7ba99ab2..530604dc 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1661,16 +1661,6 @@ def get_active_manifest(self) -> dict: C2paError: If there was an error getting the manifest JSON KeyError: If the active_manifest key is missing from the JSON ValueError: If the active manifest ID is not found in the manifests - - Example: - ```python - with Reader("image/jpeg", file) as reader: - active_manifest = reader.get_active_manifest() - print(f"Title: {active_manifest['title']}") - print(f"Format: {active_manifest['format']}") - for assertion in active_manifest['assertions']: - print(f"Assertion: {assertion['label']}") - ``` """ # Get the full manifest JSON manifest_json_str = self.json() From 05a83807dd3c9918fd4b7e4484085525860fc249 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 2 Oct 2025 21:48:03 -0700 Subject: [PATCH 06/17] fix: Clean up 2 --- src/c2pa/c2pa.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 530604dc..7fe816b8 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1662,28 +1662,24 @@ def get_active_manifest(self) -> dict: KeyError: If the active_manifest key is missing from the JSON ValueError: If the active manifest ID is not found in the manifests """ - # Get the full manifest JSON + # Self-read to get the JSON manifest_json_str = self.json() - try: - # Parse the JSON manifest_data = json.loads(manifest_json_str) except json.JSONDecodeError as e: raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e - # Extract the active manifest ID + # Get the active manfiest id/label if "active_manifest" not in manifest_data: raise KeyError("No 'active_manifest' key found in manifest data") active_manifest_id = manifest_data["active_manifest"] - # Get the manifests object to retrieve the active manifest from there + # Retrieve the active manifest data using manifest id/label if "manifests" not in manifest_data: raise KeyError("No 'manifests' key found in manifest data") manifests = manifest_data["manifests"] - - # Look up the active manifest if active_manifest_id not in manifests: raise ValueError("Active manifest idnot found in manifests") @@ -1705,12 +1701,11 @@ def get_validation_state(self) -> Optional[str]: """ manifest_json_str = self.json() try: - # Parse the JSON + # Self-read to get the JSON manifest_data = json.loads(manifest_json_str) except json.JSONDecodeError as e: raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e - # Extract the validation_state field return manifest_data.get("validation_state") def get_validation_results(self) -> Optional[dict]: @@ -1730,12 +1725,11 @@ def get_validation_results(self) -> Optional[dict]: """ manifest_json_str = self.json() try: - # Parse the JSON + # Self-read to get the JSON manifest_data = json.loads(manifest_json_str) except json.JSONDecodeError as e: raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e - # Extract the validation_results field return manifest_data.get("validation_results") def resource_to_stream(self, uri: str, stream: Any) -> int: From 826c55d2d47e414c6d9c3a7d8abe00d54d76290f Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Thu, 2 Oct 2025 22:07:56 -0700 Subject: [PATCH 07/17] fix: Some more convenience methods --- src/c2pa/c2pa.py | 35 ++++++++++++++++++++++++++++++-- tests/test_unit_tests.py | 43 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 7fe816b8..e2c0032f 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1660,7 +1660,6 @@ def get_active_manifest(self) -> dict: Raises: C2paError: If there was an error getting the manifest JSON KeyError: If the active_manifest key is missing from the JSON - ValueError: If the active manifest ID is not found in the manifests """ # Self-read to get the JSON manifest_json_str = self.json() @@ -1681,10 +1680,42 @@ def get_active_manifest(self) -> dict: manifests = manifest_data["manifests"] if active_manifest_id not in manifests: - raise ValueError("Active manifest idnot found in manifests") + raise KeyError("Active manifest id not found in manifest store") return manifests[active_manifest_id] + def get_manifest_by_label(self, label: str) -> dict: + """Get a specific manifest from the manifest store by its label. + + This method retrieves the manifest JSON and extracts the manifest + that corresponds to the provided manifest label/ID. + + Args: + label: The manifest label/ID to look up in the manifest store + + Returns: + A dictionary containing the manifest data for the specified label. + + Raises: + C2paError: If there was an error getting the manifest JSON + KeyError: If the manifests key is missing from the JSON + """ + # Self-read to get the JSON + manifest_json_str = self.json() + try: + manifest_data = json.loads(manifest_json_str) + except json.JSONDecodeError as e: + raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e + + if "manifests" not in manifest_data: + raise KeyError("No 'manifests' key found in manifest data") + + manifests = manifest_data["manifests"] + if label not in manifests: + raise KeyError("Manifest not found in manifest store") + + return manifests[label] + def get_validation_state(self) -> Optional[str]: """Get the validation state of the manifest store. diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 6eb40644..90f22156 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -64,7 +64,7 @@ def test_stream_read(self): json_data = reader.json() self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) - def test_stream_read_get_active_manifest(self): + def test_get_active_manifest(self): with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) active_manifest = reader.get_active_manifest() @@ -73,6 +73,47 @@ def test_stream_read_get_active_manifest(self): expected_label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" self.assertEqual(active_manifest["label"], expected_label) + def test_get_manifest_by_label(self): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + + # Test getting manifest by the specific label + label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" + manifest = reader.get_manifest_by_label(label) + + # Check that we got the correct manifest + self.assertEqual(manifest["label"], label) + + # Verify it's the same as the active manifest (since there's only one) + active_manifest = reader.get_active_manifest() + self.assertEqual(manifest, active_manifest) + + def test_stream_get_non_active_manifest_by_label(self): + video_path = os.path.join(FIXTURES_DIR, "video1.mp4") + with open(video_path, "rb") as file: + reader = Reader("video/mp4", file) + + non_active_label = "urn:uuid:54281c07-ad34-430e-bea5-112a18facf0b" + non_active_manifest = reader.get_manifest_by_label(non_active_label) + + # Check that we got the correct manifest + self.assertEqual(non_active_manifest["label"], non_active_label) + + # Verify it's not the active manifest + active_manifest = reader.get_active_manifest() + self.assertNotEqual(non_active_manifest, active_manifest) + self.assertNotEqual(non_active_manifest["label"], active_manifest["label"]) + + def test_stream_get_non_active_manifest_by_label_not_found(self): + video_path = os.path.join(FIXTURES_DIR, "video1.mp4") + with open(video_path, "rb") as file: + reader = Reader("video/mp4", file) + + # Try to get a manifest with a label that clearly doesn't exist + non_existing_label = "urn:uuid:clearly-not-existing" + with self.assertRaises(KeyError): + reader.get_manifest_by_label(non_existing_label) + def test_stream_read_get_validation_state(self): with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) From ba3dd4efdfb5eeafd0d182e383af04e32fa117c4 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 3 Oct 2025 11:46:30 -0700 Subject: [PATCH 08/17] fix: Test clean up --- tests/test_unit_tests.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 90f22156..2e8bbb2e 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -80,11 +80,9 @@ def test_get_manifest_by_label(self): # Test getting manifest by the specific label label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" manifest = reader.get_manifest_by_label(label) - - # Check that we got the correct manifest self.assertEqual(manifest["label"], label) - # Verify it's the same as the active manifest (since there's only one) + # It should be the active manifest too, so cross-check active_manifest = reader.get_active_manifest() self.assertEqual(manifest, active_manifest) @@ -95,8 +93,6 @@ def test_stream_get_non_active_manifest_by_label(self): non_active_label = "urn:uuid:54281c07-ad34-430e-bea5-112a18facf0b" non_active_manifest = reader.get_manifest_by_label(non_active_label) - - # Check that we got the correct manifest self.assertEqual(non_active_manifest["label"], non_active_label) # Verify it's not the active manifest From a69980de03e9d1033c906d9dbeece8e527845381 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 3 Oct 2025 11:47:33 -0700 Subject: [PATCH 09/17] fix: Test clean up 2 --- tests/test_unit_tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 2e8bbb2e..925a6b6b 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -96,6 +96,7 @@ def test_stream_get_non_active_manifest_by_label(self): self.assertEqual(non_active_manifest["label"], non_active_label) # Verify it's not the active manifest + # (that test case has only one other manifest that is not the active manifest) active_manifest = reader.get_active_manifest() self.assertNotEqual(non_active_manifest, active_manifest) self.assertNotEqual(non_active_manifest["label"], active_manifest["label"]) @@ -105,7 +106,7 @@ def test_stream_get_non_active_manifest_by_label_not_found(self): with open(video_path, "rb") as file: reader = Reader("video/mp4", file) - # Try to get a manifest with a label that clearly doesn't exist + # Try to get a manifest with a label that clearly doesn't exist... non_existing_label = "urn:uuid:clearly-not-existing" with self.assertRaises(KeyError): reader.get_manifest_by_label(non_existing_label) @@ -125,7 +126,6 @@ def test_stream_read_get_validation_results(self): self.assertIsNotNone(validation_results) self.assertIsInstance(validation_results, dict) - # Verify some active manifest validation results self.assertIn("activeManifest", validation_results) active_manifest_results = validation_results["activeManifest"] self.assertIsInstance(active_manifest_results, dict) From 44a1919d0bbcacddeeaf547f858fee202e098d52 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 3 Oct 2025 11:48:02 -0700 Subject: [PATCH 10/17] fix: Clean up --- src/c2pa/c2pa.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index e2c0032f..7a9bcb87 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1246,9 +1246,6 @@ class Reader: ``` with Reader("image/jpeg", output) as reader: manifest_json = reader.json() - active_manifest = reader.get_active_manifest() - validation_state = reader.get_validation_state() - validation_results = reader.validation_results() ``` Where `output` is either an in-memory stream or an opened file. """ From 40f75738debae2a4ec3ae4fd365abac8b26d5678 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 6 Oct 2025 15:11:51 -0700 Subject: [PATCH 11/17] fix: Reader caching --- src/c2pa/c2pa.py | 69 +++++++----- tests/test_unit_tests.py | 178 +++++++++++++++++++++++++++++- tests/test_unit_tests_threaded.py | 150 +++++++++++++++++++++++++ 3 files changed, 367 insertions(+), 30 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index c5b14d7a..6f53eab9 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1358,6 +1358,10 @@ def __init__(self, # we may have opened ourselves, and that we need to close later self._backing_file = None + # Cache for manifest JSON string and parsed data to avoid multiple calls + self._manifest_json_str_cache = None + self._manifest_data_cache = None + if stream is None: # If we don't get a stream as param: # Create a stream from the file path in format_or_path @@ -1620,6 +1624,9 @@ def close(self): Reader._ERROR_MESSAGES['cleanup_error'].format( str(e))) finally: + # Clear the cache when closing + self._manifest_json_str_cache = None + self._manifest_data_cache = None self._closed = True def json(self) -> str: @@ -1634,6 +1641,10 @@ def json(self) -> str: self._ensure_valid_state() + # Return cached result if available + if self._manifest_json_str_cache is not None: + return self._manifest_json_str_cache + result = _lib.c2pa_reader_json(self._reader) if result is None: @@ -1642,7 +1653,29 @@ def json(self) -> str: raise C2paError(error) raise C2paError("Error during manifest parsing in Reader") - return _convert_to_py_string(result) + # Cache the result and return it + self._manifest_json_str_cache = _convert_to_py_string(result) + return self._manifest_json_str_cache + + def _get_cached_manifest_data(self) -> dict: + """Get the cached manifest data, fetching and parsing if not cached. + + Returns: + A dictionary containing the parsed manifest data + + Raises: + C2paError: If there was an error getting or parsing the JSON + """ + if self._manifest_data_cache is None: + if self._manifest_json_str_cache is None: + self._manifest_json_str_cache = self.json() + + try: + self._manifest_data_cache = json.loads(self._manifest_json_str_cache) + except json.JSONDecodeError as e: + raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e + + return self._manifest_data_cache def get_active_manifest(self) -> dict: """Get the active manifest from the manifest store. @@ -1658,12 +1691,8 @@ def get_active_manifest(self) -> dict: C2paError: If there was an error getting the manifest JSON KeyError: If the active_manifest key is missing from the JSON """ - # Self-read to get the JSON - manifest_json_str = self.json() - try: - manifest_data = json.loads(manifest_json_str) - except json.JSONDecodeError as e: - raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e + # Get cached manifest data + manifest_data = self._get_cached_manifest_data() # Get the active manfiest id/label if "active_manifest" not in manifest_data: @@ -1681,7 +1710,7 @@ def get_active_manifest(self) -> dict: return manifests[active_manifest_id] - def get_manifest_by_label(self, label: str) -> dict: + def get_manifest_from_label(self, label: str) -> dict: """Get a specific manifest from the manifest store by its label. This method retrieves the manifest JSON and extracts the manifest @@ -1697,12 +1726,8 @@ def get_manifest_by_label(self, label: str) -> dict: C2paError: If there was an error getting the manifest JSON KeyError: If the manifests key is missing from the JSON """ - # Self-read to get the JSON - manifest_json_str = self.json() - try: - manifest_data = json.loads(manifest_json_str) - except json.JSONDecodeError as e: - raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e + # Get cached manifest data + manifest_data = self._get_cached_manifest_data() if "manifests" not in manifest_data: raise KeyError("No 'manifests' key found in manifest data") @@ -1727,12 +1752,8 @@ def get_validation_state(self) -> Optional[str]: Raises: C2paError: If there was an error getting the manifest JSON """ - manifest_json_str = self.json() - try: - # Self-read to get the JSON - manifest_data = json.loads(manifest_json_str) - except json.JSONDecodeError as e: - raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e + # Get cached manifest data + manifest_data = self._get_cached_manifest_data() return manifest_data.get("validation_state") @@ -1751,12 +1772,8 @@ def get_validation_results(self) -> Optional[dict]: Raises: C2paError: If there was an error getting the manifest JSON """ - manifest_json_str = self.json() - try: - # Self-read to get the JSON - manifest_data = json.loads(manifest_json_str) - except json.JSONDecodeError as e: - raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e + # Get cached manifest data + manifest_data = self._get_cached_manifest_data() return manifest_data.get("validation_results") diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 925a6b6b..cf9b3b6c 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -73,13 +73,13 @@ def test_get_active_manifest(self): expected_label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" self.assertEqual(active_manifest["label"], expected_label) - def test_get_manifest_by_label(self): + def test_get_manifest_from_label(self): with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) # Test getting manifest by the specific label label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" - manifest = reader.get_manifest_by_label(label) + manifest = reader.get_manifest_from_label(label) self.assertEqual(manifest["label"], label) # It should be the active manifest too, so cross-check @@ -92,7 +92,7 @@ def test_stream_get_non_active_manifest_by_label(self): reader = Reader("video/mp4", file) non_active_label = "urn:uuid:54281c07-ad34-430e-bea5-112a18facf0b" - non_active_manifest = reader.get_manifest_by_label(non_active_label) + non_active_manifest = reader.get_manifest_from_label(non_active_label) self.assertEqual(non_active_manifest["label"], non_active_label) # Verify it's not the active manifest @@ -109,7 +109,7 @@ def test_stream_get_non_active_manifest_by_label_not_found(self): # Try to get a manifest with a label that clearly doesn't exist... non_existing_label = "urn:uuid:clearly-not-existing" with self.assertRaises(KeyError): - reader.get_manifest_by_label(non_existing_label) + reader.get_manifest_from_label(non_existing_label) def test_stream_read_get_validation_state(self): with open(self.testPath, "rb") as file: @@ -336,6 +336,115 @@ def test_read_all_files_using_extension(self): except Exception as e: self.fail(f"Failed to read metadata from {filename}: {str(e)}") + def test_read_cached_all_files(self): + """Test reading C2PA metadata with cache functionality 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(mime_type, file) + + # Test 1: Verify cache variables are initially None + self.assertIsNone(reader._manifest_json_str_cache, f"JSON cache should be None initially for {filename}") + self.assertIsNone(reader._manifest_data_cache, f"Manifest data cache should be None initially for {filename}") + + # Test 2: Multiple calls to json() should return the same result and use cache + json_data_1 = reader.json() + self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache not set after first json() call for {filename}") + self.assertEqual(json_data_1, reader._manifest_json_str_cache, f"JSON cache doesn't match return value for {filename}") + + json_data_2 = reader.json() + self.assertEqual(json_data_1, json_data_2, f"JSON inconsistency for {filename}") + self.assertIsInstance(json_data_1, str) + + # Test 3: Test methods that use the cache + try: + # Test get_active_manifest() which uses _get_cached_manifest_data() + active_manifest = reader.get_active_manifest() + self.assertIsInstance(active_manifest, dict, f"Active manifest not dict for {filename}") + + # Test 4: Verify cache is set after calling cache-using methods + self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache not set after get_active_manifest for {filename}") + self.assertIsNotNone(reader._manifest_data_cache, f"Manifest data cache not set after get_active_manifest for {filename}") + + # Test 5: Multiple calls to cache-using methods should return the same result + active_manifest_2 = reader.get_active_manifest() + self.assertEqual(active_manifest, active_manifest_2, f"Active manifest cache inconsistency for {filename}") + + # Test get_validation_state() which uses the cache + validation_state = reader.get_validation_state() + # validation_state can be None, so just check it doesn't crash + + # Test get_validation_results() which uses the cache + validation_results = reader.get_validation_results() + # validation_results can be None, so just check it doesn't crash + + # Test 6: Multiple calls to validation methods should return the same result + validation_state_2 = reader.get_validation_state() + self.assertEqual(validation_state, validation_state_2, f"Validation state cache inconsistency for {filename}") + + validation_results_2 = reader.get_validation_results() + self.assertEqual(validation_results, validation_results_2, f"Validation results cache inconsistency for {filename}") + + except KeyError as e: + # Some files might not have active manifests or validation data + # This is expected for some test files, so we'll skip cache testing for those + pass + + # Test 7: Verify the manifest contains expected fields + manifest = json.loads(json_data_1) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + + # Test 8: Test cache clearing on close + reader.close() + self.assertIsNone(reader._manifest_json_str_cache, f"JSON cache not cleared for {filename}") + self.assertIsNone(reader._manifest_data_cache, f"Manifest data cache not cleared for {filename}") + + except Exception as e: + self.fail(f"Failed to read cached metadata from {filename}: {str(e)}") + def test_reader_context_manager_with_exception(self): """Test Reader state after exception in context manager.""" try: @@ -496,6 +605,67 @@ def test_reader_get_remote_url(self): self.assertEqual(remote_url, "https://cai-manifests.adobe.com/manifests/adobe-urn-uuid-5f37e182-3687-462e-a7fb-573462780391") self.assertFalse(reader.is_embedded()) + def test_stream_read_and_parse_cached(self): + """Test reading and parsing with cache verification by repeating operations multiple times""" + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + + # Verify cache starts as None + self.assertIsNone(reader._manifest_json_str_cache, "JSON cache should be None initially") + self.assertIsNone(reader._manifest_data_cache, "Manifest data cache should be None initially") + + # First operation - should populate cache + manifest_store_1 = json.loads(reader.json()) + title_1 = manifest_store_1["manifests"][manifest_store_1["active_manifest"]]["title"] + self.assertEqual(title_1, DEFAULT_TEST_FILE_NAME) + + # Verify cache is populated after first json() call + self.assertIsNotNone(reader._manifest_json_str_cache, "JSON cache should be set after first json() call") + self.assertEqual(manifest_store_1, json.loads(reader._manifest_json_str_cache), "Cached JSON should match parsed result") + + # Repeat the same operation multiple times to verify cache usage + for i in range(5): + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME, f"Title should be consistent on iteration {i+1}") + + # Verify cache is still populated and consistent + self.assertIsNotNone(reader._manifest_json_str_cache, f"JSON cache should remain set on iteration {i+1}") + self.assertEqual(manifest_store, json.loads(reader._manifest_json_str_cache), f"Cached JSON should match parsed result on iteration {i+1}") + + # Test methods that use the cache + # Test get_active_manifest() which uses _get_cached_manifest_data() + active_manifest_1 = reader.get_active_manifest() + self.assertIsInstance(active_manifest_1, dict, "Active manifest should be a dict") + + # Verify manifest data cache is populated + self.assertIsNotNone(reader._manifest_data_cache, "Manifest data cache should be set after get_active_manifest()") + + # Repeat get_active_manifest() multiple times to verify cache usage + for i in range(3): + active_manifest = reader.get_active_manifest() + self.assertEqual(active_manifest_1, active_manifest, f"Active manifest should be consistent on iteration {i+1}") + + # Verify cache remains populated + self.assertIsNotNone(reader._manifest_data_cache, f"Manifest data cache should remain set on iteration {i+1}") + + # Test get_validation_state() and get_validation_results() with cache + validation_state_1 = reader.get_validation_state() + validation_results_1 = reader.get_validation_results() + + # Repeat validation methods to verify cache usage + for i in range(3): + validation_state = reader.get_validation_state() + validation_results = reader.get_validation_results() + + self.assertEqual(validation_state_1, validation_state, f"Validation state should be consistent on iteration {i+1}") + self.assertEqual(validation_results_1, validation_results, f"Validation results should be consistent on iteration {i+1}") + + # Verify cache clearing on close + reader.close() + self.assertIsNone(reader._manifest_json_str_cache, "JSON cache should be cleared on close") + self.assertIsNone(reader._manifest_data_cache, "Manifest data cache should be cleared on close") + # TODO: Unskip once fixed configuration to read data is clarified # def test_read_cawg_data_file(self): # """Test reading C2PA metadata from C_with_CAWG_data.jpg file.""" diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index 3d3b6f1e..9a00dd6a 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -165,6 +165,156 @@ def process_file(filename): if errors: self.fail("\n".join(errors)) + def test_read_cached_all_files(self): + """Test reading C2PA metadata with cache functionality from all files in the fixtures/files-for-reading-tests directory using multithreading""" + 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' + } + + def process_file_with_cache(filename): + if filename in skip_files: + return None + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + return None + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + reader = Reader(mime_type, file) + + # Test 1: Verify cache variables are initially None + if reader._manifest_json_str_cache is not None: + return f"JSON cache should be None initially for {filename}" + if reader._manifest_data_cache is not None: + return f"Manifest data cache should be None initially for {filename}" + + # Test 2: Multiple calls to json() should return the same result and use cache + json_data_1 = reader.json() + if reader._manifest_json_str_cache is None: + return f"JSON cache not set after first json() call for {filename}" + if json_data_1 != reader._manifest_json_str_cache: + return f"JSON cache doesn't match return value for {filename}" + + json_data_2 = reader.json() + if json_data_1 != json_data_2: + return f"JSON inconsistency for {filename}" + if not isinstance(json_data_1, str): + return f"JSON data is not a string for {filename}" + + # Test 3: Test methods that use the cache + try: + # Test get_active_manifest() which uses _get_cached_manifest_data() + active_manifest = reader.get_active_manifest() + if not isinstance(active_manifest, dict): + return f"Active manifest not dict for {filename}" + + # Test 4: Verify cache is set after calling cache-using methods + if reader._manifest_json_str_cache is None: + return f"JSON cache not set after get_active_manifest for {filename}" + if reader._manifest_data_cache is None: + return f"Manifest data cache not set after get_active_manifest for {filename}" + + # Test 5: Multiple calls to cache-using methods should return the same result + active_manifest_2 = reader.get_active_manifest() + if active_manifest != active_manifest_2: + return f"Active manifest cache inconsistency for {filename}" + + # Test get_validation_state() which uses the cache + validation_state = reader.get_validation_state() + # validation_state can be None, so just check it doesn't crash + + # Test get_validation_results() which uses the cache + validation_results = reader.get_validation_results() + # validation_results can be None, so just check it doesn't crash + + # Test 6: Multiple calls to validation methods should return the same result + validation_state_2 = reader.get_validation_state() + if validation_state != validation_state_2: + return f"Validation state cache inconsistency for {filename}" + + validation_results_2 = reader.get_validation_results() + if validation_results != validation_results_2: + return f"Validation results cache inconsistency for {filename}" + + except KeyError: + # Some files might not have active manifests or validation data + # This is expected for some test files, so we'll skip cache testing for those + pass + + # Test 7: Verify the manifest contains expected fields + manifest = json.loads(json_data_1) + if "manifests" not in manifest: + return f"Missing 'manifests' key in {filename}" + if "active_manifest" not in manifest: + return f"Missing 'active_manifest' key in {filename}" + + # Test 8: Test cache clearing on close + reader.close() + if reader._manifest_json_str_cache is not None: + return f"JSON cache not cleared for {filename}" + if reader._manifest_data_cache is not None: + return f"Manifest data cache not cleared for {filename}" + + return None # Success case returns None + + except Exception as e: + return f"Failed to read cached metadata from {filename}: {str(e)}" + + # Create a thread pool with 6 workers + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + # Submit all files to the thread pool + future_to_file = { + executor.submit(process_file_with_cache, filename): filename + for filename in os.listdir(reading_dir) + } + + # Collect results as they complete + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append( + f"Unexpected error processing {filename}: {str(e)}") + + # If any errors occurred, fail the test with all error messages + if errors: + self.fail("\n".join(errors)) class TestBuilderWithThreads(unittest.TestCase): def setUp(self): From 2d793cf29e5689ab09b06b528f9e53f999b7dc44 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 6 Oct 2025 15:41:25 -0700 Subject: [PATCH 12/17] fix: Update formats --- src/c2pa/c2pa.py | 147 +++++++++++++++++++++++++++-------------------- 1 file changed, 85 insertions(+), 62 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 6f53eab9..d7b50837 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1358,7 +1358,7 @@ def __init__(self, # we may have opened ourselves, and that we need to close later self._backing_file = None - # Cache for manifest JSON string and parsed data to avoid multiple calls + # Caches for manifest JSON string and parsed data self._manifest_json_str_cache = None self._manifest_data_cache = None @@ -1604,6 +1604,30 @@ def _cleanup_resources(self): # Ensure we don't raise exceptions during cleanup pass + def _get_cached_manifest_data(self) -> Optional[dict]: + """Get the cached manifest data, fetching and parsing if not cached. + + Returns: + A dictionary containing the parsed manifest data, or None if + JSON parsing fails + + Raises: + C2paError: If there was an error getting the JSON + """ + if self._manifest_data_cache is None: + if self._manifest_json_str_cache is None: + self._manifest_json_str_cache = self.json() + + try: + self._manifest_data_cache = json.loads( + self._manifest_json_str_cache + ) + except json.JSONDecodeError: + # Failed to parse manifest JSON + return None + + return self._manifest_data_cache + def close(self): """Release the reader resources. @@ -1657,27 +1681,7 @@ def json(self) -> str: self._manifest_json_str_cache = _convert_to_py_string(result) return self._manifest_json_str_cache - def _get_cached_manifest_data(self) -> dict: - """Get the cached manifest data, fetching and parsing if not cached. - - Returns: - A dictionary containing the parsed manifest data - - Raises: - C2paError: If there was an error getting or parsing the JSON - """ - if self._manifest_data_cache is None: - if self._manifest_json_str_cache is None: - self._manifest_json_str_cache = self.json() - - try: - self._manifest_data_cache = json.loads(self._manifest_json_str_cache) - except json.JSONDecodeError as e: - raise C2paError(f"Failed to parse manifest JSON: {str(e)}") from e - - return self._manifest_data_cache - - def get_active_manifest(self) -> dict: + def get_active_manifest(self) -> Optional[dict]: """Get the active manifest from the manifest store. This method retrieves the full manifest JSON and extracts the active @@ -1685,32 +1689,38 @@ def get_active_manifest(self) -> dict: Returns: A dictionary containing the active manifest data, including claims, - assertions, ingredients, and signature information. + assertions, ingredients, and signature information, or None if no + manifest is found or if there was an error parsing the JSON. Raises: - C2paError: If there was an error getting the manifest JSON KeyError: If the active_manifest key is missing from the JSON """ - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() + try: + # Get cached manifest data + manifest_data = self._get_cached_manifest_data() + if manifest_data is None: + # raise C2paError("Failed to parse manifest JSON") + return None - # Get the active manfiest id/label - if "active_manifest" not in manifest_data: - raise KeyError("No 'active_manifest' key found in manifest data") + # Get the active manfiest id/label + if "active_manifest" not in manifest_data: + raise KeyError("No 'active_manifest' key found") - active_manifest_id = manifest_data["active_manifest"] + active_manifest_id = manifest_data["active_manifest"] - # Retrieve the active manifest data using manifest id/label - if "manifests" not in manifest_data: - raise KeyError("No 'manifests' key found in manifest data") + # Retrieve the active manifest data using manifest id/label + if "manifests" not in manifest_data: + raise KeyError("No 'manifests' key found in manifest data") - manifests = manifest_data["manifests"] - if active_manifest_id not in manifests: - raise KeyError("Active manifest id not found in manifest store") + manifests = manifest_data["manifests"] + if active_manifest_id not in manifests: + raise KeyError("Active manifest not found in manifest store") - return manifests[active_manifest_id] + return manifests[active_manifest_id] + except C2paError.ManifestNotFound: + return None - def get_manifest_from_label(self, label: str) -> dict: + def get_manifest_from_label(self, label: str) -> Optional[dict]: """Get a specific manifest from the manifest store by its label. This method retrieves the manifest JSON and extracts the manifest @@ -1720,23 +1730,30 @@ def get_manifest_from_label(self, label: str) -> dict: label: The manifest label/ID to look up in the manifest store Returns: - A dictionary containing the manifest data for the specified label. + A dictionary containing the manifest data for the specified label, + or None if no manifest is found or if there was an error parsing + the JSON. Raises: - C2paError: If there was an error getting the manifest JSON KeyError: If the manifests key is missing from the JSON """ - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() + try: + # Get cached manifest data + manifest_data = self._get_cached_manifest_data() + if manifest_data is None: + # raise C2paError("Failed to parse manifest JSON") + return None - if "manifests" not in manifest_data: - raise KeyError("No 'manifests' key found in manifest data") + if "manifests" not in manifest_data: + raise KeyError("No 'manifests' key found in manifest data") - manifests = manifest_data["manifests"] - if label not in manifests: - raise KeyError("Manifest not found in manifest store") + manifests = manifest_data["manifests"] + if label not in manifests: + raise KeyError(f"Manifest {label} not found in manifest store") - return manifests[label] + return manifests[label] + except C2paError.ManifestNotFound: + return None def get_validation_state(self) -> Optional[str]: """Get the validation state of the manifest store. @@ -1747,15 +1764,18 @@ def get_validation_state(self) -> Optional[str]: Returns: The validation state as a string, - or None if the validation_state field is not present. - - Raises: - C2paError: If there was an error getting the manifest JSON + or None if the validation_state field is not present or if no + manifest is found or if there was an error parsing the JSON. """ - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() + try: + # Get cached manifest data + manifest_data = self._get_cached_manifest_data() + if manifest_data is None: + return None - return manifest_data.get("validation_state") + return manifest_data.get("validation_state") + except C2paError.ManifestNotFound: + return None def get_validation_results(self) -> Optional[dict]: """Get the validation results of the manifest store. @@ -1767,15 +1787,18 @@ def get_validation_results(self) -> Optional[dict]: Returns: The validation results as a dictionary containing validation details, or None if the validation_results - field is not present. - - Raises: - C2paError: If there was an error getting the manifest JSON + field is not present or if no manifest is found or if + there was an error parsing the JSON. """ - # Get cached manifest data - manifest_data = self._get_cached_manifest_data() + try: + # Get cached manifest data + manifest_data = self._get_cached_manifest_data() + if manifest_data is None: + return None - return manifest_data.get("validation_results") + return manifest_data.get("validation_results") + except C2paError.ManifestNotFound: + return None def resource_to_stream(self, uri: str, stream: Any) -> int: """Write a resource to a stream. From 163814fbbb0bb027a87a98b7c21f262af43c36f2 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 6 Oct 2025 15:45:08 -0700 Subject: [PATCH 13/17] fix: Workflow, add a check-format step --- .github/workflows/build.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 87276b6c..0aeaa868 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,6 +36,29 @@ jobs: id: read-version run: echo "version=$(cat c2pa-native-version.txt | tr -d '\r\n')" >> $GITHUB_OUTPUT + check-format: + name: Check code format and syntax + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install development dependencies + run: python -m pip install -r requirements-dev.txt + + - name: Check Python syntax + run: python3 -m py_compile src/c2pa/c2pa.py + continue-on-error: true + + - name: Check code style with flake8 + run: flake8 src/c2pa/c2pa.py + continue-on-error: true + tests-unix: name: Unit tests for developer setup (Unix) needs: read-version From 8e7e3bce37346f0ec29802a202605f645b0c44d1 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 6 Oct 2025 15:46:17 -0700 Subject: [PATCH 14/17] fix: Rename the flow --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0aeaa868..3bb6e130 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,7 @@ jobs: run: echo "version=$(cat c2pa-native-version.txt | tr -d '\r\n')" >> $GITHUB_OUTPUT check-format: - name: Check code format and syntax + name: Check code format runs-on: ubuntu-latest steps: - name: Checkout repository From fddc183e4698ad6a9d4ff0dc26715fad537b1e7c Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 6 Oct 2025 15:50:47 -0700 Subject: [PATCH 15/17] fix: Do not cache error states --- src/c2pa/c2pa.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index d7b50837..38fba0ce 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1623,6 +1623,9 @@ def _get_cached_manifest_data(self) -> Optional[dict]: self._manifest_json_str_cache ) except json.JSONDecodeError: + # Reset cache to reattempt read, possibly + self._manifest_data_cache = None + self._manifest_json_str_cache = None # Failed to parse manifest JSON return None From 1c4ce6b82b571a8b0c6c1d85f2c474a8ec98f495 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 7 Oct 2025 13:12:07 -0700 Subject: [PATCH 16/17] fix: Rename --- src/c2pa/c2pa.py | 2 +- tests/test_unit_tests.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 38fba0ce..99617750 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1723,7 +1723,7 @@ def get_active_manifest(self) -> Optional[dict]: except C2paError.ManifestNotFound: return None - def get_manifest_from_label(self, label: str) -> Optional[dict]: + def get_manifest(self, label: str) -> Optional[dict]: """Get a specific manifest from the manifest store by its label. This method retrieves the manifest JSON and extracts the manifest diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index cf9b3b6c..7575aba7 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -73,13 +73,13 @@ def test_get_active_manifest(self): expected_label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" self.assertEqual(active_manifest["label"], expected_label) - def test_get_manifest_from_label(self): + def test_get_manifest(self): with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) # Test getting manifest by the specific label label = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" - manifest = reader.get_manifest_from_label(label) + manifest = reader.get_manifest(label) self.assertEqual(manifest["label"], label) # It should be the active manifest too, so cross-check @@ -92,7 +92,7 @@ def test_stream_get_non_active_manifest_by_label(self): reader = Reader("video/mp4", file) non_active_label = "urn:uuid:54281c07-ad34-430e-bea5-112a18facf0b" - non_active_manifest = reader.get_manifest_from_label(non_active_label) + non_active_manifest = reader.get_manifest(non_active_label) self.assertEqual(non_active_manifest["label"], non_active_label) # Verify it's not the active manifest @@ -109,7 +109,7 @@ def test_stream_get_non_active_manifest_by_label_not_found(self): # Try to get a manifest with a label that clearly doesn't exist... non_existing_label = "urn:uuid:clearly-not-existing" with self.assertRaises(KeyError): - reader.get_manifest_from_label(non_existing_label) + reader.get_manifest(non_existing_label) def test_stream_read_get_validation_state(self): with open(self.testPath, "rb") as file: From c1ce1fdde30204a296f69021d6d490693f71f982 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 7 Oct 2025 13:13:56 -0700 Subject: [PATCH 17/17] fix: Prepare version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 83afff78..7a98b4a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.25.0" +version = "0.26.0" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" }