c2pa-python 0.27.1__tar.gz → 0.28.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {c2pa_python-0.27.1/src/c2pa_python.egg-info → c2pa_python-0.28.0}/PKG-INFO +2 -1
  2. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/README.md +1 -0
  3. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/pyproject.toml +1 -1
  4. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/src/c2pa/__init__.py +4 -2
  5. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/src/c2pa/c2pa.py +32 -0
  6. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/src/c2pa/lib.py +41 -11
  7. {c2pa_python-0.27.1 → c2pa_python-0.28.0/src/c2pa_python.egg-info}/PKG-INFO +2 -1
  8. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/tests/test_unit_tests.py +327 -5
  9. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/LICENSE-APACHE +0 -0
  10. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/LICENSE-MIT +0 -0
  11. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/MANIFEST.in +0 -0
  12. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/requirements.txt +0 -0
  13. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/scripts/download_artifacts.py +0 -0
  14. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/setup.cfg +0 -0
  15. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/setup.py +0 -0
  16. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/src/c2pa/build.py +0 -0
  17. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/src/c2pa_python.egg-info/SOURCES.txt +0 -0
  18. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/src/c2pa_python.egg-info/dependency_links.txt +0 -0
  19. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/src/c2pa_python.egg-info/entry_points.txt +0 -0
  20. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/src/c2pa_python.egg-info/requires.txt +0 -0
  21. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/src/c2pa_python.egg-info/top_level.txt +0 -0
  22. {c2pa_python-0.27.1 → c2pa_python-0.28.0}/tests/test_unit_tests_threaded.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.27.1
3
+ Version: 0.28.0
4
4
  Summary: Python bindings for the C2PA Content Authenticity Initiative (CAI) library
5
5
  Author-email: Gavin Peacock <gvnpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
6
6
  Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
@@ -57,6 +57,7 @@ import c2pa
57
57
 
58
58
  See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/main/examples) for some helpful examples:
59
59
 
60
+ - `examples/read.py` shows how to read and verify an asset with a C2PA manifest.
60
61
  - `examples/sign.py` shows how to sign and verify an asset with a C2PA manifest.
61
62
  - `examples/training.py` demonstrates how to add a "Do Not Train" assertion to an asset and verify it.
62
63
 
@@ -34,6 +34,7 @@ import c2pa
34
34
 
35
35
  See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/main/examples) for some helpful examples:
36
36
 
37
+ - `examples/read.py` shows how to read and verify an asset with a C2PA manifest.
37
38
  - `examples/sign.py` shows how to sign and verify an asset with a C2PA manifest.
38
39
  - `examples/training.py` demonstrates how to add a "Do Not Train" assertion to an asset and verify it.
39
40
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "c2pa-python"
7
- version = "0.27.1"
7
+ version = "0.28.0"
8
8
  requires-python = ">=3.10"
9
9
  description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library"
10
10
  readme = { file = "README.md", content-type = "text/markdown" }
@@ -26,7 +26,8 @@ from .c2pa import (
26
26
  Signer,
27
27
  Stream,
28
28
  sdk_version,
29
- read_ingredient_file
29
+ read_ingredient_file,
30
+ load_settings
30
31
  ) # NOQA
31
32
 
32
33
  # Re-export C2paError and its subclasses
@@ -39,5 +40,6 @@ __all__ = [
39
40
  'Signer',
40
41
  'Stream',
41
42
  'sdk_version',
42
- 'read_ingredient_file'
43
+ 'read_ingredient_file',
44
+ 'load_settings'
43
45
  ]
@@ -41,6 +41,7 @@ _REQUIRED_FUNCTIONS = [
41
41
  'c2pa_reader_from_manifest_data_and_stream',
42
42
  'c2pa_reader_free',
43
43
  'c2pa_reader_json',
44
+ 'c2pa_reader_detailed_json',
44
45
  'c2pa_reader_resource_to_stream',
45
46
  'c2pa_builder_from_json',
46
47
  'c2pa_builder_from_archive',
@@ -379,6 +380,9 @@ _setup_function(_lib.c2pa_reader_free, [ctypes.POINTER(C2paReader)], None)
379
380
  _setup_function(
380
381
  _lib.c2pa_reader_json, [
381
382
  ctypes.POINTER(C2paReader)], ctypes.c_void_p)
383
+ _setup_function(
384
+ _lib.c2pa_reader_detailed_json, [
385
+ ctypes.POINTER(C2paReader)], ctypes.c_void_p)
382
386
  _setup_function(_lib.c2pa_reader_resource_to_stream, [ctypes.POINTER(
383
387
  C2paReader), ctypes.c_char_p, ctypes.POINTER(C2paStream)], ctypes.c_int64)
384
388
  _setup_function(
@@ -1737,6 +1741,34 @@ class Reader:
1737
1741
  self._manifest_json_str_cache = _convert_to_py_string(result)
1738
1742
  return self._manifest_json_str_cache
1739
1743
 
1744
+ def detailed_json(self) -> str:
1745
+ """Get the detailed JSON representation of the C2PA manifest store.
1746
+
1747
+ This method returns a more detailed JSON string than Reader.json(),
1748
+ providing additional information about the manifest structure.
1749
+ Note that the returned JSON by this method has a slightly different
1750
+ structure than the one returned by Reader.json().
1751
+
1752
+ Returns:
1753
+ A JSON string containing the detailed manifest store data.
1754
+
1755
+ Raises:
1756
+ C2paError: If there is an error reading the manifest data or if
1757
+ the Reader has been closed.
1758
+ """
1759
+
1760
+ self._ensure_valid_state()
1761
+
1762
+ result = _lib.c2pa_reader_detailed_json(self._reader)
1763
+
1764
+ if result is None:
1765
+ error = _parse_operation_result_for_error(_lib.c2pa_error())
1766
+ if error:
1767
+ raise C2paError(error)
1768
+ raise C2paError("Error during detailed manifest parsing in Reader")
1769
+
1770
+ return _convert_to_py_string(result)
1771
+
1740
1772
  def get_active_manifest(self) -> Optional[dict]:
1741
1773
  """Get the active manifest from the manifest store.
1742
1774
 
@@ -106,7 +106,7 @@ def _get_platform_dir() -> str:
106
106
 
107
107
 
108
108
  def _load_single_library(lib_name: str,
109
- search_paths: list[Path]) -> Optional[ctypes.CDLL]:
109
+ search_paths: list[Path]) -> tuple[Optional[ctypes.CDLL], list[tuple[Path, Exception]]]:
110
110
  """
111
111
  Load a single library from the given search paths.
112
112
 
@@ -115,7 +115,7 @@ def _load_single_library(lib_name: str,
115
115
  search_paths: List of paths to search for the library
116
116
 
117
117
  Returns:
118
- The loaded library or None if loading failed
118
+ A tuple of (loaded library or None, list of (path, error) for files that were found but failed to load)
119
119
  """
120
120
  if DEBUG_LIBRARY_LOADING: # pragma: no cover
121
121
  logger.info(f"Searching for library '{lib_name}' in paths: {[str(p) for p in search_paths]}")
@@ -123,6 +123,8 @@ def _load_single_library(lib_name: str,
123
123
  if DEBUG_LIBRARY_LOADING: # pragma: no cover
124
124
  logger.info(f"Current architecture: {current_arch}")
125
125
 
126
+ load_errors = []
127
+
126
128
  for path in search_paths:
127
129
  lib_path = path / lib_name
128
130
  if DEBUG_LIBRARY_LOADING: # pragma: no cover
@@ -131,17 +133,20 @@ def _load_single_library(lib_name: str,
131
133
  if DEBUG_LIBRARY_LOADING: # pragma: no cover
132
134
  logger.info(f"Found library at: {lib_path}")
133
135
  try:
134
- return ctypes.CDLL(str(lib_path))
136
+ return ctypes.CDLL(str(lib_path)), []
135
137
  except Exception as e:
136
138
  error_msg = str(e)
139
+ load_errors.append((lib_path, e))
137
140
  if "incompatible architecture" in error_msg:
138
141
  logger.error(f"Architecture mismatch: Library at {lib_path} is not compatible with current architecture {current_arch}")
139
142
  logger.error(f"Error details: {error_msg}")
143
+ elif "GLIBC" in error_msg or "version" in error_msg.lower():
144
+ logger.error(f"Library dependency error at {lib_path}: {error_msg}")
140
145
  else:
141
146
  logger.error(f"Failed to load library from {lib_path}: {e}")
142
147
  else:
143
148
  logger.debug(f"Library not found at: {lib_path}")
144
- return None
149
+ return None, load_errors
145
150
 
146
151
 
147
152
  def _get_possible_search_paths() -> list[Path]:
@@ -250,19 +255,44 @@ def dynamically_load_library(
250
255
 
251
256
  if lib_name:
252
257
  # If specific library name is provided, only load that one
253
- lib = _load_single_library(lib_name, possible_paths)
258
+ lib, load_errors = _load_single_library(lib_name, possible_paths)
254
259
  if not lib:
255
260
  platform_id = get_platform_identifier()
256
261
  current_arch = _get_architecture()
257
- logger.error(f"Could not find {lib_name} in any of the search paths: {[str(p) for p in possible_paths]}")
258
- logger.error(f"Platform: {platform_id}, Architecture: {current_arch}")
259
- raise RuntimeError(f"Could not find {lib_name} in any of the search paths (Platform: {platform_id}, Architecture: {current_arch})")
262
+
263
+ if load_errors:
264
+ # Library files were found but failed to load
265
+ error_details = "\n".join([f" - {path}: {error}" for path, error in load_errors])
266
+ logger.error(f"Found {lib_name} but failed to load it. Errors encountered:")
267
+ logger.error(error_details)
268
+ logger.error(f"Platform: {platform_id}, Architecture: {current_arch}")
269
+ raise RuntimeError(
270
+ f"Found {lib_name} at {len(load_errors)} location(s) but failed to load:\n{error_details}\n"
271
+ f"Platform: {platform_id}, Architecture: {current_arch}"
272
+ )
273
+ else:
274
+ # Library file was not found in any search path
275
+ logger.error(f"Could not find {lib_name} in any of the search paths: {[str(p) for p in possible_paths]}")
276
+ logger.error(f"Platform: {platform_id}, Architecture: {current_arch}")
277
+ raise RuntimeError(
278
+ f"Could not find {lib_name} in any of the search paths (Platform: {platform_id}, Architecture: {current_arch})"
279
+ )
260
280
  return lib
261
281
 
262
282
  # Default path (no library name provided in the environment)
263
- c2pa_lib = _load_single_library(c2pa_lib_name, possible_paths)
283
+ c2pa_lib, load_errors = _load_single_library(c2pa_lib_name, possible_paths)
264
284
  if not c2pa_lib:
265
- logger.error(f"Could not find {c2pa_lib_name} in any of the search paths: {[str(p) for p in possible_paths]}")
266
- raise RuntimeError(f"Could not find {c2pa_lib_name} in any of the search paths")
285
+ if load_errors:
286
+ # Library files were found but failed to load
287
+ error_details = "\n".join([f" - {path}: {error}" for path, error in load_errors])
288
+ logger.error(f"Found {c2pa_lib_name} but failed to load it. Errors encountered:")
289
+ logger.error(error_details)
290
+ raise RuntimeError(
291
+ f"Found {c2pa_lib_name} at {len(load_errors)} location(s) but failed to load:\n{error_details}"
292
+ )
293
+ else:
294
+ # Library file was not found in any search path
295
+ logger.error(f"Could not find {c2pa_lib_name} in any of the search paths: {[str(p) for p in possible_paths]}")
296
+ raise RuntimeError(f"Could not find {c2pa_lib_name} in any of the search paths")
267
297
 
268
298
  return c2pa_lib
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.27.1
3
+ Version: 0.28.0
4
4
  Summary: Python bindings for the C2PA Content Authenticity Initiative (CAI) library
5
5
  Author-email: Gavin Peacock <gvnpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
6
6
  Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
@@ -57,6 +57,7 @@ import c2pa
57
57
 
58
58
  See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/main/examples) for some helpful examples:
59
59
 
60
+ - `examples/read.py` shows how to read and verify an asset with a C2PA manifest.
60
61
  - `examples/sign.py` shows how to sign and verify an asset with a C2PA manifest.
61
62
  - `examples/training.py` demonstrates how to add a "Do Not Train" assertion to an asset and verify it.
62
63
 
@@ -64,6 +64,12 @@ class TestReader(unittest.TestCase):
64
64
  json_data = reader.json()
65
65
  self.assertIn(DEFAULT_TEST_FILE_NAME, json_data)
66
66
 
67
+ def test_stream_read_detailed(self):
68
+ with open(self.testPath, "rb") as file:
69
+ reader = Reader("image/jpeg", file)
70
+ json_data = reader.detailed_json()
71
+ self.assertIn(DEFAULT_TEST_FILE_NAME, json_data)
72
+
67
73
  def test_get_active_manifest(self):
68
74
  with open(self.testPath, "rb") as file:
69
75
  reader = Reader("image/jpeg", file)
@@ -142,6 +148,13 @@ class TestReader(unittest.TestCase):
142
148
  title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"]
143
149
  self.assertEqual(title, DEFAULT_TEST_FILE_NAME)
144
150
 
151
+ def test_stream_read_detailed_and_parse(self):
152
+ with open(self.testPath, "rb") as file:
153
+ reader = Reader("image/jpeg", file)
154
+ manifest_store = json.loads(reader.detailed_json())
155
+ title = manifest_store["manifests"][manifest_store["active_manifest"]]["claim"]["dc:title"]
156
+ self.assertEqual(title, DEFAULT_TEST_FILE_NAME)
157
+
145
158
  def test_stream_read_string_stream(self):
146
159
  with Reader(self.testPath) as reader:
147
160
  json_data = reader.json()
@@ -564,7 +577,8 @@ class TestReader(unittest.TestCase):
564
577
  "data": {
565
578
  "actions": [
566
579
  {
567
- "action": "c2pa.opened"
580
+ "action": "c2pa.created",
581
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"
568
582
  }
569
583
  ]
570
584
  }
@@ -732,8 +746,8 @@ class TestBuilderWithSigner(unittest.TestCase):
732
746
  "data": {
733
747
  "actions": [
734
748
  {
735
- "action": "c2pa.opened"
736
- # Should have more parameters here, but omitted in tests
749
+ "action": "c2pa.created",
750
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"
737
751
  }
738
752
  ]
739
753
  }
@@ -2214,8 +2228,9 @@ class TestBuilderWithSigner(unittest.TestCase):
2214
2228
  "data": {
2215
2229
  "actions": [
2216
2230
  {
2217
- "action": "c2pa.opened",
2218
- "description": "Opened with Unicode: test"
2231
+ "action": "c2pa.created",
2232
+ "description": "Unicode: test",
2233
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"
2219
2234
  }
2220
2235
  ]
2221
2236
  }
@@ -2911,6 +2926,313 @@ class TestBuilderWithSigner(unittest.TestCase):
2911
2926
  # Reset settings
2912
2927
  load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true}}}}')
2913
2928
 
2929
+ def test_builder_opened_action_one_ingredient_no_auto_add(self):
2930
+ """Test Builder with c2pa.opened action and one ingredient, following Adobe provenance patterns"""
2931
+ # Disable auto-added actions
2932
+ load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}')
2933
+
2934
+ # Instance IDs for linking ingredients and actions
2935
+ # This can be any unique id so the ingredient can be uniquely identified and linked to the action
2936
+ parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c"
2937
+
2938
+ manifestDefinition = {
2939
+ "claim_generator_info": [{
2940
+ "name": "Python CAI test",
2941
+ "version": "3.14.16"
2942
+ }],
2943
+ "title": "A title for the provenance test",
2944
+ "ingredients": [
2945
+ # The parent ingredient will be added through add_ingredient
2946
+ # And a properly crafted manifest json so they link
2947
+ ],
2948
+ "assertions": [
2949
+ {
2950
+ "label": "c2pa.actions.v2",
2951
+ "data": {
2952
+ "actions": [
2953
+ {
2954
+ "action": "c2pa.opened",
2955
+ "softwareAgent": {
2956
+ "name": "Opened asset",
2957
+ },
2958
+ "parameters": {
2959
+ "ingredientIds": [
2960
+ parent_ingredient_id
2961
+ ]
2962
+ },
2963
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
2964
+ }
2965
+ ]
2966
+ }
2967
+ }
2968
+ ]
2969
+ }
2970
+
2971
+ # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition
2972
+ # Aka the unique parent_ingredient_id we rely on for linking
2973
+ ingredient_json = {
2974
+ "relationship": "parentOf",
2975
+ "instance_id": parent_ingredient_id
2976
+ }
2977
+ # An opened ingredient is always a parent, and there can only be exactly one parent ingredient
2978
+
2979
+ # Read the input file (A.jpg will be signed)
2980
+ with open(self.testPath2, "rb") as test_file:
2981
+ file_content = test_file.read()
2982
+
2983
+ builder = Builder.from_json(manifestDefinition)
2984
+
2985
+ # Add C.jpg as the parent "opened" ingredient
2986
+ with open(self.testPath, 'rb') as f:
2987
+ builder.add_ingredient(ingredient_json, "image/jpeg", f)
2988
+
2989
+ output_buffer = io.BytesIO(bytearray())
2990
+ builder.sign(
2991
+ self.signer,
2992
+ "image/jpeg",
2993
+ io.BytesIO(file_content),
2994
+ output_buffer)
2995
+ output_buffer.seek(0)
2996
+
2997
+ # Read and verify the manifest
2998
+ reader = Reader("image/jpeg", output_buffer)
2999
+ json_data = reader.json()
3000
+ manifest_data = json.loads(json_data)
3001
+
3002
+ # Verify the ingredient instance ID is present
3003
+ self.assertIn(parent_ingredient_id, json_data)
3004
+
3005
+ # Verify c2pa.opened action is present
3006
+ self.assertIn("c2pa.opened", json_data)
3007
+
3008
+ builder.close()
3009
+
3010
+ # Make sure settings are put back to the common test defaults
3011
+ load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}')
3012
+
3013
+ def test_builder_one_opened_one_placed_action_no_auto_add(self):
3014
+ """Test Builder with c2pa.opened action where asset is its own parent ingredient"""
3015
+ # Disable auto-added actions
3016
+ load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}')
3017
+
3018
+ # Instance IDs for linking ingredients and actions,
3019
+ # need to be unique even if the same binary file is used, so ingredients link properly to actions
3020
+ parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c"
3021
+ placed_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-f3f800ebe42b"
3022
+
3023
+ manifestDefinition = {
3024
+ "claim_generator_info": [{
3025
+ "name": "Python CAI test",
3026
+ "version": "0.2.942"
3027
+ }],
3028
+ "title": "A title for the provenance test",
3029
+ "ingredients": [
3030
+ # The parent ingredient will be added through add_ingredient
3031
+ {
3032
+ # Represents the bubbled up AI asset/ingredient
3033
+ "format": "jpeg",
3034
+ "relationship": "componentOf",
3035
+ # Instance ID must be generated to match what is in parameters ingredientIds array
3036
+ "instance_id": placed_ingredient_id,
3037
+ }
3038
+ ],
3039
+ "assertions": [
3040
+ {
3041
+ "label": "c2pa.actions.v2",
3042
+ "data": {
3043
+ "actions": [
3044
+ {
3045
+ "action": "c2pa.opened",
3046
+ "softwareAgent": {
3047
+ "name": "Opened asset",
3048
+ },
3049
+ "parameters": {
3050
+ "ingredientIds": [
3051
+ parent_ingredient_id
3052
+ ]
3053
+ },
3054
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
3055
+ },
3056
+ {
3057
+ "action": "c2pa.placed",
3058
+ "softwareAgent": {
3059
+ "name": "Placed asset",
3060
+ },
3061
+ "parameters": {
3062
+ "ingredientIds": [
3063
+ placed_ingredient_id
3064
+ ]
3065
+ },
3066
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
3067
+ }
3068
+ ]
3069
+ }
3070
+ }
3071
+ ]
3072
+ }
3073
+
3074
+ # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition for c2pa.opened
3075
+ # So that ingredients can link together.
3076
+ ingredient_json = {
3077
+ "relationship": "parentOf",
3078
+ "when": "2025-08-07T18:01:55.934Z",
3079
+ "instance_id": parent_ingredient_id
3080
+ }
3081
+
3082
+ # Read the input file (A.jpg will be signed)
3083
+ with open(self.testPath2, "rb") as test_file:
3084
+ file_content = test_file.read()
3085
+
3086
+ builder = Builder.from_json(manifestDefinition)
3087
+
3088
+ # An asset can be its own parent ingredient!
3089
+ # We add A.jpg as its own parent ingredient
3090
+ with open(self.testPath2, 'rb') as f:
3091
+ builder.add_ingredient(ingredient_json, "image/jpeg", f)
3092
+
3093
+ output_buffer = io.BytesIO(bytearray())
3094
+ builder.sign(
3095
+ self.signer,
3096
+ "image/jpeg",
3097
+ io.BytesIO(file_content),
3098
+ output_buffer)
3099
+ output_buffer.seek(0)
3100
+
3101
+ # Read and verify the manifest
3102
+ reader = Reader("image/jpeg", output_buffer)
3103
+ json_data = reader.json()
3104
+ manifest_data = json.loads(json_data)
3105
+
3106
+ # Verify both ingredient instance IDs are present
3107
+ self.assertIn(parent_ingredient_id, json_data)
3108
+ self.assertIn(placed_ingredient_id, json_data)
3109
+
3110
+ # Verify both actions are present
3111
+ self.assertIn("c2pa.opened", json_data)
3112
+ self.assertIn("c2pa.placed", json_data)
3113
+
3114
+ builder.close()
3115
+
3116
+ # Make sure settings are put back to the common test defaults
3117
+ load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}')
3118
+
3119
+ def test_builder_opened_action_multiple_ingredient_no_auto_add(self):
3120
+ """Test Builder with c2pa.opened and c2pa.placed actions with multiple ingredients"""
3121
+ # Disable auto-added actions, as what we are doing here can confuse auto-placements
3122
+ load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}')
3123
+
3124
+ # Instance IDs for linking ingredients and actions
3125
+ # With multiple ingredients, we need multiple different unique ids so they each link properly
3126
+ parent_ingredient_id = "xmp:iid:a965983b-36fb-445a-aa80-a2d911dcc53c"
3127
+ placed_ingredient_1_id = "xmp:iid:a965983b-36fb-445a-aa80-f3f800ebe42b"
3128
+ placed_ingredient_2_id = "xmp:iid:a965983b-36fb-445a-aa80-f2d712acd14c"
3129
+
3130
+ manifestDefinition = {
3131
+ "claim_generator_info": [{
3132
+ "name": "Python CAI test",
3133
+ "version": "0.2.942"
3134
+ }],
3135
+ "title": "A title for the provenance test with multiple ingredients",
3136
+ "ingredients": [
3137
+ # More ingredients will be added using add_ingredient
3138
+ {
3139
+ "format": "jpeg",
3140
+ "relationship": "componentOf",
3141
+ # Instance ID must be generated to match what is in parameters ingredientIds array
3142
+ "instance_id": placed_ingredient_1_id,
3143
+ }
3144
+ ],
3145
+ "assertions": [
3146
+ {
3147
+ "label": "c2pa.actions.v2",
3148
+ "data": {
3149
+ "actions": [
3150
+ {
3151
+ "action": "c2pa.opened",
3152
+ "softwareAgent": {
3153
+ "name": "A parent opened asset",
3154
+ },
3155
+ "parameters": {
3156
+ "ingredientIds": [
3157
+ parent_ingredient_id
3158
+ ]
3159
+ },
3160
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
3161
+ },
3162
+ {
3163
+ "action": "c2pa.placed",
3164
+ "softwareAgent": {
3165
+ "name": "Component placed assets",
3166
+ },
3167
+ "parameters": {
3168
+ "ingredientIds": [
3169
+ placed_ingredient_1_id,
3170
+ placed_ingredient_2_id
3171
+ ]
3172
+ },
3173
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
3174
+ }
3175
+ ]
3176
+ }
3177
+ }
3178
+ ]
3179
+ }
3180
+
3181
+ # The ingredient json for the opened action needs to match the instance_id in the manifestDefinition,
3182
+ # so that ingredients properly link with their action
3183
+ ingredient_json_parent = {
3184
+ "relationship": "parentOf",
3185
+ "instance_id": parent_ingredient_id
3186
+ }
3187
+
3188
+ # The ingredient json for the placed action needs to match the instance_id in the manifestDefinition,
3189
+ # so that ingredients properly link with their action
3190
+ ingredient_json_placed = {
3191
+ "relationship": "componentOf",
3192
+ "instance_id": placed_ingredient_2_id
3193
+ }
3194
+
3195
+ # Read the input file (A.jpg will be signed)
3196
+ with open(self.testPath2, "rb") as test_file:
3197
+ file_content = test_file.read()
3198
+
3199
+ builder = Builder.from_json(manifestDefinition)
3200
+
3201
+ # Add C.jpg as the parent ingredient (for c2pa.opened, it's the opened asset)
3202
+ with open(self.testPath, 'rb') as f1:
3203
+ builder.add_ingredient(ingredient_json_parent, "image/jpeg", f1)
3204
+
3205
+ # Add cloud.jpg as another placed ingredient (for instance, added on the opened asset)
3206
+ with open(self.testPath4, 'rb') as f2:
3207
+ builder.add_ingredient(ingredient_json_placed, "image/jpeg", f2)
3208
+
3209
+ output_buffer = io.BytesIO(bytearray())
3210
+ builder.sign(
3211
+ self.signer,
3212
+ "image/jpeg",
3213
+ io.BytesIO(file_content),
3214
+ output_buffer)
3215
+ output_buffer.seek(0)
3216
+
3217
+ # Read and verify the manifest
3218
+ reader = Reader("image/jpeg", output_buffer)
3219
+ json_data = reader.json()
3220
+ manifest_data = json.loads(json_data)
3221
+
3222
+ # Verify all ingredient instance IDs are present
3223
+ self.assertIn(parent_ingredient_id, json_data)
3224
+ self.assertIn(placed_ingredient_1_id, json_data)
3225
+ self.assertIn(placed_ingredient_2_id, json_data)
3226
+
3227
+ # Verify both actions are present
3228
+ self.assertIn("c2pa.opened", json_data)
3229
+ self.assertIn("c2pa.placed", json_data)
3230
+
3231
+ builder.close()
3232
+
3233
+ # Make sure settings are put back to the common test defaults
3234
+ load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}')
3235
+
2914
3236
 
2915
3237
  class TestStream(unittest.TestCase):
2916
3238
  def setUp(self):
File without changes
File without changes
File without changes
File without changes