diff --git a/pyproject.toml b/pyproject.toml index 7a98b4a7..f872e362 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.26.0" +version = "0.27.0" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 99617750..b0b7e5db 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -49,6 +49,7 @@ 'c2pa_builder_set_remote_url', 'c2pa_builder_add_resource', 'c2pa_builder_add_ingredient_from_stream', + 'c2pa_builder_add_action', 'c2pa_builder_to_archive', 'c2pa_builder_sign', 'c2pa_manifest_bytes_free', @@ -403,6 +404,9 @@ def _setup_function(func, argtypes, restype=None): ctypes.c_char_p, ctypes.POINTER(C2paStream)], ctypes.c_int) +_setup_function(_lib.c2pa_builder_add_action, + [ctypes.POINTER(C2paBuilder), ctypes.c_char_p], + ctypes.c_int) _setup_function(_lib.c2pa_builder_to_archive, [ctypes.POINTER(C2paBuilder), ctypes.POINTER(C2paStream)], ctypes.c_int) @@ -667,20 +671,44 @@ def version() -> str: return _convert_to_py_string(result) +@overload def load_settings(settings: str, format: str = "json") -> None: - """Load C2PA settings from a string. + ... + + +@overload +def load_settings(settings: dict) -> None: + ... + + +def load_settings(settings: Union[str, dict], format: str = "json") -> None: + """Load C2PA settings from a string or dict. Args: - settings: The settings string to load - format: The format of the settings string (default: "json") + settings: The settings string or dict to load + format: The format of the settings string (default: "json"). + Ignored when settings is a dict. Raises: C2paError: If there was an error loading the settings """ - result = _lib.c2pa_load_settings( - settings.encode('utf-8'), - format.encode('utf-8') - ) + # Convert to JSON string as necessary + try: + if isinstance(settings, dict): + settings_str = json.dumps(settings) + format = "json" + else: + settings_str = settings + except (TypeError, ValueError) as e: + raise C2paError(f"Failed to serialize settings to JSON: {e}") + + try: + settings_bytes = settings_str.encode('utf-8') + format_bytes = format.encode('utf-8') + except (AttributeError, UnicodeEncodeError) as e: + raise C2paError(f"Failed to encode settings to UTF-8: {e}") + + result = _lib.c2pa_load_settings(settings_bytes, format_bytes) if result != 0: error = _parse_operation_result_for_error(_lib.c2pa_error()) if error: @@ -2198,6 +2226,7 @@ class Builder: 'url_error': "Error setting remote URL: {}", 'resource_error': "Error adding resource: {}", 'ingredient_error': "Error adding ingredient: {}", + 'action_error': "Error adding action: {}", 'archive_error': "Error writing archive: {}", 'sign_error': "Error during signing: {}", 'encoding_error': "Invalid UTF-8 characters in manifest: {}", @@ -2495,13 +2524,16 @@ def add_resource(self, uri: str, stream: Any): ) ) - def add_ingredient(self, ingredient_json: str, format: str, source: Any): + def add_ingredient( + self, ingredient_json: Union[str, dict], format: str, source: Any + ): """Add an ingredient to the builder (facade method). The added ingredient's source should be a stream-like object (for instance, a file opened as stream). Args: ingredient_json: The JSON ingredient definition + (either a JSON string or a dictionary) format: The MIME type or extension of the ingredient source: The stream containing the ingredient data (any Python stream-like object) @@ -2521,7 +2553,7 @@ def add_ingredient(self, ingredient_json: str, format: str, source: Any): def add_ingredient_from_stream( self, - ingredient_json: str, + ingredient_json: Union[str, dict], format: str, source: Any): """Add an ingredient from a stream to the builder. @@ -2529,6 +2561,7 @@ def add_ingredient_from_stream( Args: ingredient_json: The JSON ingredient definition + (either a JSON string or a dictionary) format: The MIME type or extension of the ingredient source: The stream containing the ingredient data (any Python stream-like object) @@ -2540,6 +2573,9 @@ def add_ingredient_from_stream( """ self._ensure_valid_state() + if isinstance(ingredient_json, dict): + ingredient_json = json.dumps(ingredient_json) + try: ingredient_str = ingredient_json.encode('utf-8') format_str = format.encode('utf-8') @@ -2570,7 +2606,7 @@ def add_ingredient_from_stream( def add_ingredient_from_file_path( self, - ingredient_json: str, + ingredient_json: Union[str, dict], format: str, filepath: Union[str, Path]): """Add an ingredient from a file path to the builder (deprecated). @@ -2582,6 +2618,7 @@ def add_ingredient_from_file_path( Args: ingredient_json: The JSON ingredient definition + (either a JSON string or a dictionary) format: The MIME type or extension of the ingredient filepath: The path to the file containing the ingredient data (can be a string or Path object) @@ -2613,6 +2650,42 @@ def add_ingredient_from_file_path( except Exception as e: raise C2paError.Other(f"Could not add ingredient: {e}") from e + def add_action(self, action_json: Union[str, dict]) -> None: + """Add an action to the builder, that will be placed + in the actions assertion array in the generated manifest. + + Args: + action_json: The JSON action definition + (either a JSON string or a dictionary) + + Raises: + C2paError: If there was an error adding the action + C2paError.Encoding: If the action JSON contains invalid UTF-8 chars + """ + self._ensure_valid_state() + + if isinstance(action_json, dict): + action_json = json.dumps(action_json) + + try: + action_str = action_json.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + Builder._ERROR_MESSAGES['encoding_error'].format(str(e)) + ) + + result = _lib.c2pa_builder_add_action(self._builder, action_str) + + if result != 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + Builder._ERROR_MESSAGES['action_error'].format( + "Unknown error" + ) + ) + def to_archive(self, stream: Any) -> None: """Write an archive of the builder to a stream. diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 7575aba7..7bdb8897 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -733,6 +733,7 @@ def setUp(self): "actions": [ { "action": "c2pa.opened" + # Should have more parameters here, but omitted in tests } ] } @@ -742,7 +743,6 @@ def setUp(self): # Define a V2 manifest as a dictionary self.manifestDefinitionV2 = { - "claim_generator": "python_test", "claim_generator_info": [{ "name": "python_test", "version": "0.0.1", @@ -1289,6 +1289,17 @@ def test_builder_add_ingredient(self): builder.close() + def test_builder_add_ingredient_dict(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Test adding ingredient with a dictionary instead of JSON string + ingredient_dict = {"test": "ingredient"} + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_dict, "image/jpeg", f) + + builder.close() + def test_builder_add_multiple_ingredients(self): builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None @@ -1309,6 +1320,26 @@ def test_builder_add_multiple_ingredients(self): builder.close() + def test_builder_add_multiple_ingredients_2(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Test builder operations + builder.set_no_embed() + builder.set_remote_url("http://test.url") + + # Test adding ingredient with a dictionary + ingredient_dict = {"test": "ingredient"} + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_dict, "image/jpeg", f) + + # Test adding another ingredient with a JSON string + ingredient_json = '{"test": "ingredient2"}' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/png", f) + + builder.close() + def test_builder_add_multiple_ingredients_and_resources(self): builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None @@ -1451,6 +1482,53 @@ def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): # Settings are thread-local, so we reset to the default "true" here load_settings('{"builder": { "thumbnail": {"enabled": true}}}') + def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # The following removes the manifest's thumbnail - using dict instead of string + load_settings({"builder": {"thumbnail": {"enabled": False}}}) + + # Test adding ingredient + ingredient_json = '{ "title": "Test Ingredient" }' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # There should be no thumbnail anymore here + self.assertNotIn("thumbnail", active_manifest) + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + self.assertNotIn("thumbnail", first_ingredient) + + builder.close() + + # Settings are thread-local, so we reset to the default "true" here - using dict instead of string + load_settings({"builder": {"thumbnail": {"enabled": True}}}) + def test_builder_sign_with_duplicate_ingredient(self): builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None @@ -1537,6 +1615,46 @@ def test_builder_sign_with_ingredient_from_stream(self): builder.close() + def test_builder_sign_with_ingredient_dict_from_stream(self): + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Test adding ingredient using stream with a dictionary + ingredient_dict = {"title": "Test Ingredient Stream"} + with open(self.testPath3, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_dict, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual( + first_ingredient["title"], + "Test Ingredient Stream") + + builder.close() + def test_builder_sign_with_multiple_ingredient(self): builder = Builder.from_json(self.manifestDefinition) assert builder._builder is not None @@ -2323,6 +2441,476 @@ def test_builder_state_with_invalid_native_pointer(self): with self.assertRaises(Error): builder.set_no_embed() + def test_builder_add_action_to_manifest_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + # claim version 2 is the default + # "claim_version": 2, + "format": "image/jpeg", + "title": "Python Test Image V2", + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + builder = Builder.from_json(initial_manifest_definition) + + action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' + builder.add_action(action_json) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break + + self.assertTrue(created_action_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + + def test_builder_add_action_to_manifest_from_dict_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + # claim version 2 is the default + # "claim_version": 2, + "format": "image/jpeg", + "title": "Python Test Image V2", + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + builder = Builder.from_json(initial_manifest_definition) + + # Using a dictionary instead of a JSON string + action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} + builder.add_action(action_dict) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break + + self.assertTrue(created_action_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + + def test_builder_add_action_to_manifest_with_auto_add(self): + # For testing, force settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + # claim version 2 is the default + # "claim_version": 2, + "format": "image/jpeg", + "title": "Python Test Image V2", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + builder = Builder.from_json(initial_manifest_definition) + + action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' + builder.add_action(action_json) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break + + self.assertTrue(created_action_found) + + # Verify "c2pa.created" action exists only once in the actions array + created_count = 0 + for action in actions: + if action.get("action") == "c2pa.created": + created_count += 1 + + self.assertEqual(created_count, 1, "c2pa.created action should appear exactly once") + + builder.close() + + # Reset settings to default + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + + def test_builder_minimal_manifest_add_actions_and_sign_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + initial_manifest_definition = { + "claim_generator": "python_test", + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image V2", + } + + builder = Builder.from_json(initial_manifest_definition) + builder.add_action('{ "action": "c2pa.created", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}') + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to look for what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.created" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.created": + created_action_found = True + break + + self.assertTrue(created_action_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + + def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image V2", + } + + builder = Builder.from_json(initial_manifest_definition) + action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' + builder.add_action(action_json) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to look for what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.created" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.created": + created_action_found = True + break + + self.assertTrue(created_action_found) + + # Verify "c2pa.color_adjustments" action also exists in the same actions array + color_adjustments_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + color_adjustments_found = True + break + + self.assertTrue(color_adjustments_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + + def test_builder_sign_dicts_no_auto_add(self): + # For testing, remove auto-added actions + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + + initial_manifest_definition = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + # claim version 2 is the default + # "claim_version": 2, + "format": "image/jpeg", + "title": "Python Test Image V2", + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" + } + ] + } + } + ] + } + builder = Builder.from_json(initial_manifest_definition) + + # Using a dictionary instead of a JSON string + action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} + builder.add_action(action_dict) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify assertions object exists in active manifest + self.assertIn("assertions", active_manifest) + assertions = active_manifest["assertions"] + + # Find the c2pa.actions.v2 assertion to check what we added + actions_assertion = None + for assertion in assertions: + if assertion.get("label") == "c2pa.actions.v2": + actions_assertion = assertion + break + + self.assertIsNotNone(actions_assertion) + self.assertIn("data", actions_assertion) + assertion_data = actions_assertion["data"] + # Verify the manifest now contains actions + self.assertIn("actions", assertion_data) + actions = assertion_data["actions"] + # Verify "c2pa.color_adjustments" action exists anywhere in the actions array + created_action_found = False + for action in actions: + if action.get("action") == "c2pa.color_adjustments": + created_action_found = True + break + + self.assertTrue(created_action_found) + + builder.close() + + # Reset settings + load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}') + class TestStream(unittest.TestCase): def setUp(self): @@ -2786,6 +3374,46 @@ def test_builder_sign_with_ingredient_from_file(self): builder.close() + def test_builder_sign_with_ingredient_dict_from_file(self): + """Test Builder class operations with an ingredient added from file path using a dictionary.""" + + builder = Builder.from_json(self.manifestDefinition) + + # Test adding ingredient from file path with a dictionary + ingredient_dict = {"title": "Test Ingredient From File"} + # Suppress the specific deprecation warning for this test, as this is a legacy method + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + builder.add_ingredient_from_file_path(ingredient_dict, "image/jpeg", self.testPath3) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient From File") + + builder.close() + def test_builder_add_ingredient_from_file_path(self): """Test Builder class add_ingredient_from_file_path method."""