c2pa-python 0.35.0__tar.gz → 0.35.2__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.35.0/src/c2pa_python.egg-info → c2pa_python-0.35.2}/PKG-INFO +2 -2
  2. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/pyproject.toml +3 -2
  3. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/src/c2pa/c2pa.py +92 -14
  4. {c2pa_python-0.35.0 → c2pa_python-0.35.2/src/c2pa_python.egg-info}/PKG-INFO +2 -2
  5. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/tests/test_unit_tests.py +670 -1
  6. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/LICENSE-APACHE +0 -0
  7. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/LICENSE-MIT +0 -0
  8. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/MANIFEST.in +0 -0
  9. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/README.md +0 -0
  10. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/requirements.txt +0 -0
  11. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/scripts/download_artifacts.py +0 -0
  12. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/setup.cfg +0 -0
  13. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/setup.py +0 -0
  14. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/src/c2pa/__init__.py +0 -0
  15. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/src/c2pa/build.py +0 -0
  16. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/src/c2pa/lib.py +0 -0
  17. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/src/c2pa_python.egg-info/SOURCES.txt +0 -0
  18. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/src/c2pa_python.egg-info/dependency_links.txt +0 -0
  19. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/src/c2pa_python.egg-info/entry_points.txt +0 -0
  20. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/src/c2pa_python.egg-info/requires.txt +0 -0
  21. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/src/c2pa_python.egg-info/top_level.txt +0 -0
  22. {c2pa_python-0.35.0 → c2pa_python-0.35.2}/tests/test_unit_tests_threaded.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.35.0
3
+ Version: 0.35.2
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
- Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
6
+ Maintainer-email: Gavin Peacock <gpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
7
7
  License: MIT OR Apache-2.0
8
8
  Project-URL: homepage, https://contentauthenticity.org
9
9
  Project-URL: repository, https://github.com/contentauth/c2pa-python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "c2pa-python"
7
- version = "0.35.0"
7
+ version = "0.35.2"
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" }
@@ -17,7 +17,8 @@ classifiers = [
17
17
  "Operating System :: Microsoft :: Windows"
18
18
  ]
19
19
  maintainers = [
20
- {name = "Gavin Peacock", email = "gpeacock@adobe.com"}
20
+ {name = "Gavin Peacock", email = "gpeacock@adobe.com"},
21
+ {name = "Tania Mathern", email = "mathern@adobe.com"}
21
22
  ]
22
23
  urls = {homepage = "https://contentauthenticity.org", repository = "https://github.com/contentauth/c2pa-python"}
23
24
  dependencies = [
@@ -18,6 +18,7 @@ import logging
18
18
  import sys
19
19
  import os
20
20
  import warnings
21
+ import weakref
21
22
  from abc import ABC, abstractmethod
22
23
  from pathlib import Path
23
24
  from typing import Optional, Union, Callable, Any, overload
@@ -65,6 +66,8 @@ _REQUIRED_FUNCTIONS = [
65
66
  'c2pa_builder_add_ingredient_from_stream',
66
67
  'c2pa_builder_add_action',
67
68
  'c2pa_builder_to_archive',
69
+ 'c2pa_builder_write_ingredient_archive',
70
+ 'c2pa_builder_add_ingredient_from_archive',
68
71
  'c2pa_builder_sign',
69
72
  'c2pa_builder_sign_context',
70
73
  'c2pa_builder_from_context',
@@ -623,6 +626,15 @@ _setup_function(_lib.c2pa_builder_add_action,
623
626
  _setup_function(_lib.c2pa_builder_to_archive,
624
627
  [ctypes.POINTER(C2paBuilder), ctypes.POINTER(C2paStream)],
625
628
  ctypes.c_int)
629
+ _setup_function(_lib.c2pa_builder_write_ingredient_archive,
630
+ [ctypes.POINTER(C2paBuilder),
631
+ ctypes.c_char_p,
632
+ ctypes.POINTER(C2paStream)],
633
+ ctypes.c_int)
634
+ _setup_function(_lib.c2pa_builder_add_ingredient_from_archive,
635
+ [ctypes.POINTER(C2paBuilder),
636
+ ctypes.POINTER(C2paStream)],
637
+ ctypes.c_int)
626
638
  _setup_function(_lib.c2pa_builder_sign,
627
639
  [ctypes.POINTER(C2paBuilder),
628
640
  ctypes.c_char_p,
@@ -1614,6 +1626,10 @@ class Stream:
1614
1626
 
1615
1627
  self._file_like_stream = file_like_stream
1616
1628
 
1629
+ # Weakref breaks avoids references cycles:
1630
+ # Stream -> ctypes_cb -> closure -> Stream.
1631
+ _weak_self = weakref.ref(self)
1632
+
1617
1633
  def read_callback(ctx, data, length):
1618
1634
  """Callback function for reading data from the Python stream.
1619
1635
 
@@ -1632,13 +1648,16 @@ class Stream:
1632
1648
  Returns:
1633
1649
  Number of bytes read, or -1 on error
1634
1650
  """
1635
- if not self._initialized or self._closed:
1651
+ s = _weak_self()
1652
+ if s is None:
1653
+ return -1
1654
+ if not s._initialized or s._closed:
1636
1655
  return -1
1637
1656
  try:
1638
1657
  if not data or length <= 0:
1639
1658
  return -1
1640
1659
 
1641
- stream = self._file_like_stream
1660
+ stream = s._file_like_stream
1642
1661
  readinto = getattr(stream, "readinto", None)
1643
1662
  if readinto is not None:
1644
1663
  # Most streams have readinto
@@ -1689,8 +1708,11 @@ class Stream:
1689
1708
  Returns:
1690
1709
  New position in the stream, or -1 on error
1691
1710
  """
1692
- file_stream = self._file_like_stream
1693
- if not self._initialized or self._closed:
1711
+ s = _weak_self()
1712
+ if s is None:
1713
+ return -1
1714
+ file_stream = s._file_like_stream
1715
+ if not s._initialized or s._closed:
1694
1716
  return -1
1695
1717
  try:
1696
1718
  # Fall back to tell() only for stream objects that do not
@@ -1718,13 +1740,16 @@ class Stream:
1718
1740
  Returns:
1719
1741
  Number of bytes written, or -1 on error
1720
1742
  """
1721
- if not self._initialized or self._closed:
1743
+ s = _weak_self()
1744
+ if s is None:
1745
+ return -1
1746
+ if not s._initialized or s._closed:
1722
1747
  return -1
1723
1748
  try:
1724
1749
  if not data or length <= 0:
1725
1750
  return -1
1726
1751
 
1727
- self._file_like_stream.write(ctypes.string_at(data, length))
1752
+ s._file_like_stream.write(ctypes.string_at(data, length))
1728
1753
  return length
1729
1754
  except Exception:
1730
1755
  return -1
@@ -1743,10 +1768,13 @@ class Stream:
1743
1768
  Returns:
1744
1769
  0 on success, -1 on error
1745
1770
  """
1746
- if not self._initialized or self._closed:
1771
+ s = _weak_self()
1772
+ if s is None:
1773
+ return -1
1774
+ if not s._initialized or s._closed:
1747
1775
  return -1
1748
1776
  try:
1749
- self._file_like_stream.flush()
1777
+ s._file_like_stream.flush()
1750
1778
  return 0
1751
1779
  except Exception:
1752
1780
  return -1
@@ -2235,6 +2263,9 @@ class Reader(ManagedResource):
2235
2263
  if not context.is_valid:
2236
2264
  raise C2paError("Context is not valid")
2237
2265
 
2266
+ if manifest_data is not None and not isinstance(manifest_data, bytes):
2267
+ raise TypeError(Reader._ERROR_MESSAGES['manifest_error'])
2268
+
2238
2269
  # Determine format and open stream
2239
2270
  supported = Reader.get_supported_mime_types()
2240
2271
 
@@ -2275,10 +2306,6 @@ class Reader(ManagedResource):
2275
2306
  raise
2276
2307
 
2277
2308
  if manifest_data is not None:
2278
- if not isinstance(manifest_data, bytes):
2279
- raise TypeError(
2280
- Reader._ERROR_MESSAGES[
2281
- 'manifest_error'])
2282
2309
  manifest_array = (
2283
2310
  ctypes.c_ubyte *
2284
2311
  len(manifest_data)).from_buffer_copy(manifest_data)
@@ -2294,14 +2321,15 @@ class Reader(ManagedResource):
2294
2321
  len(manifest_data),
2295
2322
  )
2296
2323
  )
2297
- # reader_ptr has been invalidated(consumed)
2298
2324
  else:
2299
2325
  # Consume reader with stream
2300
2326
  new_ptr = _lib.c2pa_reader_with_stream(
2301
2327
  reader_ptr, format_bytes,
2302
2328
  self._own_stream._stream,
2303
2329
  )
2304
- # reader_ptr has been invalidated(consumed)
2330
+
2331
+ # reader_ptr has been consumed by the FFI call.
2332
+ reader_ptr = None
2305
2333
 
2306
2334
  self._handle = new_ptr
2307
2335
 
@@ -2898,6 +2926,7 @@ class Builder(ManagedResource):
2898
2926
  'url_error': "Error setting remote URL: {}",
2899
2927
  'resource_error': "Error adding resource: {}",
2900
2928
  'ingredient_error': "Error adding ingredient: {}",
2929
+ 'archive_read_error': "Error loading ingredient from archive: {}",
2901
2930
  'action_error': "Error adding action: {}",
2902
2931
  'archive_error': "Error writing archive: {}",
2903
2932
  'sign_error': "Error during signing: {}",
@@ -3287,6 +3316,55 @@ class Builder(ManagedResource):
3287
3316
  Builder._ERROR_MESSAGES["archive_error"].format("Unknown error"),
3288
3317
  check=lambda r: r != 0)
3289
3318
 
3319
+ def write_ingredient_archive(self, ingredient_id: str, stream: Any) -> None:
3320
+ """Write a single-ingredient archive for the named ingredient.
3321
+ The archive is in C2PA format.
3322
+
3323
+ Args:
3324
+ ingredient_id: Identifier of the ingredient within this builder;
3325
+ matched against the ingredient's label or instance_id
3326
+ stream: Writable, seekable stream to receive the archive
3327
+
3328
+ Raises:
3329
+ C2paError: If there was an error writing the archive
3330
+ """
3331
+ self._ensure_valid_state()
3332
+
3333
+ ingredient_id_str = _to_utf8_bytes(ingredient_id, "ingredient_id")
3334
+
3335
+ with Stream(stream) as stream_obj:
3336
+ result = _lib.c2pa_builder_write_ingredient_archive(
3337
+ self._handle, ingredient_id_str, stream_obj._stream)
3338
+
3339
+ _check_ffi_operation_result(result,
3340
+ Builder._ERROR_MESSAGES["archive_error"].format(
3341
+ "Unknown error"
3342
+ ),
3343
+ check=lambda r: r != 0)
3344
+
3345
+ def add_ingredient_from_archive(self, stream: Any) -> None:
3346
+ """Add an ingredient from a per-ingredient archive stream.
3347
+ The archive must be in C2PA format, as written by
3348
+ write_ingredient_archive.
3349
+
3350
+ Args:
3351
+ stream: Readable, seekable stream containing the ingredient archive
3352
+
3353
+ Raises:
3354
+ C2paError: If there was an error reading the archive
3355
+ """
3356
+ self._ensure_valid_state()
3357
+
3358
+ with Stream(stream) as stream_obj:
3359
+ result = _lib.c2pa_builder_add_ingredient_from_archive(
3360
+ self._handle, stream_obj._stream)
3361
+
3362
+ _check_ffi_operation_result(result,
3363
+ Builder._ERROR_MESSAGES["archive_read_error"].format(
3364
+ "Unknown error"
3365
+ ),
3366
+ check=lambda r: r != 0)
3367
+
3290
3368
  def with_archive(self, stream: Any) -> 'Builder':
3291
3369
  """Load an archive into this Builder instance, replacing its
3292
3370
  manifest definition. The archive carries only the
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.35.0
3
+ Version: 0.35.2
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
- Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
6
+ Maintainer-email: Gavin Peacock <gpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
7
7
  License: MIT OR Apache-2.0
8
8
  Project-URL: homepage, https://contentauthenticity.org
9
9
  Project-URL: repository, https://github.com/contentauth/c2pa-python
@@ -23,7 +23,6 @@ from cryptography.hazmat.backends import default_backend
23
23
  import tempfile
24
24
  import shutil
25
25
  import ctypes
26
- import toml
27
26
  import threading
28
27
 
29
28
  # Suppress deprecation warnings
@@ -1945,6 +1944,500 @@ class TestBuilderWithSigner(unittest.TestCase):
1945
1944
  self.assertIsNotNone(result.get('validation_state'))
1946
1945
  self.assertEqual(result.get('validation_state'), "Trusted")
1947
1946
 
1947
+ def test_write_ingredient_archive_produces_readable_archive(self):
1948
+ manifest = {
1949
+ "claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}],
1950
+ "assertions": [],
1951
+ }
1952
+ builder = Builder.from_json(manifest)
1953
+ ingredient_json = {
1954
+ "title": "A.jpg",
1955
+ "relationship": "componentOf",
1956
+ "instance_id": "ingredient-A",
1957
+ }
1958
+ with open(self.testPath, "rb") as f:
1959
+ builder.add_ingredient(ingredient_json, "image/jpeg", f)
1960
+ archive = io.BytesIO()
1961
+ builder.write_ingredient_archive("ingredient-A", archive)
1962
+ builder.close()
1963
+ self.assertGreater(len(archive.getvalue()), 0)
1964
+ archive.close()
1965
+
1966
+ def test_add_ingredient_from_archive_roundtrip(self):
1967
+ manifest = {
1968
+ "claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}],
1969
+ "assertions": [],
1970
+ }
1971
+ builder = Builder.from_json(manifest)
1972
+ ingredient_json = {
1973
+ "title": "A.jpg",
1974
+ "relationship": "componentOf",
1975
+ "instance_id": "catalog:ingredient-A",
1976
+ }
1977
+ with open(self.testPath, "rb") as f:
1978
+ builder.add_ingredient(ingredient_json, "image/jpeg", f)
1979
+ archive = io.BytesIO()
1980
+ builder.write_ingredient_archive("catalog:ingredient-A", archive)
1981
+ builder.close()
1982
+
1983
+ archive.seek(0)
1984
+ builder2 = Builder.from_json(manifest)
1985
+ builder2.add_ingredient_from_archive(archive)
1986
+ archive.close()
1987
+
1988
+ output = io.BytesIO()
1989
+ with open(self.testPath, "rb") as f:
1990
+ builder2.sign(self.signer, "image/jpeg", f, output)
1991
+ builder2.close()
1992
+
1993
+ output.seek(0)
1994
+ reader = Reader("image/jpeg", output)
1995
+ json_data = reader.json()
1996
+ self.assertIn("A.jpg", json_data)
1997
+ output.close()
1998
+
1999
+ def test_add_ingredient_from_archive_preserves_instance_id(self):
2000
+ manifest = {
2001
+ "claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}],
2002
+ "assertions": [],
2003
+ }
2004
+ archive_builder = Builder.from_json(manifest)
2005
+ ingredient_json = {
2006
+ "title": "photo.jpg",
2007
+ "relationship": "parentOf",
2008
+ "instance_id": "my-ingredient",
2009
+ }
2010
+ with open(self.testPath, "rb") as f:
2011
+ archive_builder.add_ingredient(ingredient_json, "image/jpeg", f)
2012
+ archive = io.BytesIO()
2013
+ archive_builder.write_ingredient_archive("my-ingredient", archive)
2014
+ archive_builder.close()
2015
+
2016
+ archive.seek(0)
2017
+ signing_builder = Builder.from_json(manifest)
2018
+ signing_builder.add_ingredient_from_archive(archive)
2019
+ archive.close()
2020
+
2021
+ output = io.BytesIO()
2022
+ with open(self.testPath, "rb") as f:
2023
+ signing_builder.sign(self.signer, "image/jpeg", f, output)
2024
+ signing_builder.close()
2025
+
2026
+ output.seek(0)
2027
+ reader = Reader("image/jpeg", output)
2028
+ json_data = reader.json()
2029
+ self.assertIn("photo.jpg", json_data)
2030
+ output.close()
2031
+
2032
+ def test_add_ingredient_from_archive_preserves_instance_id_component_of(self):
2033
+ manifest = {
2034
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2035
+ "assertions": [],
2036
+ }
2037
+ archive_builder = Builder.from_json(manifest)
2038
+ ingredient_json = {
2039
+ "title": "photo.jpg",
2040
+ "relationship": "componentOf",
2041
+ "instance_id": "my-ingredient",
2042
+ }
2043
+ with open(self.testPath, "rb") as f:
2044
+ archive_builder.add_ingredient(ingredient_json, "image/jpeg", f)
2045
+ archive = io.BytesIO()
2046
+ archive_builder.write_ingredient_archive("my-ingredient", archive)
2047
+ archive_builder.close()
2048
+
2049
+ archive.seek(0)
2050
+ signing_builder = Builder.from_json(manifest)
2051
+ signing_builder.add_ingredient_from_archive(archive)
2052
+ archive.close()
2053
+
2054
+ output = io.BytesIO()
2055
+ with open(self.testPath, "rb") as f:
2056
+ signing_builder.sign(self.signer, "image/jpeg", f, output)
2057
+ signing_builder.close()
2058
+
2059
+ output.seek(0)
2060
+ reader = Reader("image/jpeg", output)
2061
+ json_data = reader.json()
2062
+ self.assertIn("photo.jpg", json_data)
2063
+ self.assertIn("componentOf", json_data)
2064
+ output.close()
2065
+
2066
+ def test_add_ingredient_from_archive_preserves_instance_id_input_to(self):
2067
+ manifest = {
2068
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2069
+ "assertions": [],
2070
+ }
2071
+ archive_builder = Builder.from_json(manifest)
2072
+ ingredient_json = {
2073
+ "title": "photo.jpg",
2074
+ "relationship": "inputTo",
2075
+ "instance_id": "my-ingredient",
2076
+ }
2077
+ with open(self.testPath, "rb") as f:
2078
+ archive_builder.add_ingredient(ingredient_json, "image/jpeg", f)
2079
+ archive = io.BytesIO()
2080
+ archive_builder.write_ingredient_archive("my-ingredient", archive)
2081
+ archive_builder.close()
2082
+
2083
+ archive.seek(0)
2084
+ signing_builder = Builder.from_json(manifest)
2085
+ signing_builder.add_ingredient_from_archive(archive)
2086
+ archive.close()
2087
+
2088
+ output = io.BytesIO()
2089
+ with open(self.testPath, "rb") as f:
2090
+ signing_builder.sign(self.signer, "image/jpeg", f, output)
2091
+ signing_builder.close()
2092
+
2093
+ output.seek(0)
2094
+ reader = Reader("image/jpeg", output)
2095
+ json_data = reader.json()
2096
+ self.assertIn("photo.jpg", json_data)
2097
+ self.assertIn("inputTo", json_data)
2098
+ output.close()
2099
+
2100
+ def test_add_ingredient_from_archive_roundtrip_parent_of(self):
2101
+ manifest = {
2102
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2103
+ "assertions": [],
2104
+ }
2105
+ builder = Builder.from_json(manifest)
2106
+ ingredient_json = {
2107
+ "title": "A.jpg",
2108
+ "relationship": "parentOf",
2109
+ "instance_id": "catalog:ingredient-A",
2110
+ }
2111
+ with open(self.testPath, "rb") as f:
2112
+ builder.add_ingredient(ingredient_json, "image/jpeg", f)
2113
+ archive = io.BytesIO()
2114
+ builder.write_ingredient_archive("catalog:ingredient-A", archive)
2115
+ builder.close()
2116
+
2117
+ archive.seek(0)
2118
+ builder2 = Builder.from_json(manifest)
2119
+ builder2.add_ingredient_from_archive(archive)
2120
+ archive.close()
2121
+
2122
+ output = io.BytesIO()
2123
+ with open(self.testPath, "rb") as f:
2124
+ builder2.sign(self.signer, "image/jpeg", f, output)
2125
+ builder2.close()
2126
+
2127
+ output.seek(0)
2128
+ reader = Reader("image/jpeg", output)
2129
+ json_data = reader.json()
2130
+ self.assertIn("A.jpg", json_data)
2131
+ self.assertIn("parentOf", json_data)
2132
+ output.close()
2133
+
2134
+ def test_add_ingredient_from_archive_roundtrip_input_to(self):
2135
+ manifest = {
2136
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2137
+ "assertions": [],
2138
+ }
2139
+ builder = Builder.from_json(manifest)
2140
+ ingredient_json = {
2141
+ "title": "A.jpg",
2142
+ "relationship": "inputTo",
2143
+ "instance_id": "catalog:ingredient-A",
2144
+ }
2145
+ with open(self.testPath, "rb") as f:
2146
+ builder.add_ingredient(ingredient_json, "image/jpeg", f)
2147
+ archive = io.BytesIO()
2148
+ builder.write_ingredient_archive("catalog:ingredient-A", archive)
2149
+ builder.close()
2150
+
2151
+ archive.seek(0)
2152
+ builder2 = Builder.from_json(manifest)
2153
+ builder2.add_ingredient_from_archive(archive)
2154
+ archive.close()
2155
+
2156
+ output = io.BytesIO()
2157
+ with open(self.testPath, "rb") as f:
2158
+ builder2.sign(self.signer, "image/jpeg", f, output)
2159
+ builder2.close()
2160
+
2161
+ output.seek(0)
2162
+ reader = Reader("image/jpeg", output)
2163
+ json_data = reader.json()
2164
+ self.assertIn("A.jpg", json_data)
2165
+ self.assertIn("inputTo", json_data)
2166
+ output.close()
2167
+
2168
+ def test_ingredient_from_archive_linked_to_opened_action(self):
2169
+ ingredient_id = "xmp:iid:archive-parent-001"
2170
+ manifest = {
2171
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2172
+ "assertions": [
2173
+ {
2174
+ "label": "c2pa.actions.v2",
2175
+ "data": {
2176
+ "actions": [
2177
+ {
2178
+ "action": "c2pa.opened",
2179
+ "parameters": {"ingredientIds": [ingredient_id]},
2180
+ }
2181
+ ]
2182
+ },
2183
+ }
2184
+ ],
2185
+ }
2186
+ archive_builder = Builder.from_json(manifest)
2187
+ with open(self.testPath, "rb") as f:
2188
+ archive_builder.add_ingredient(
2189
+ {"title": "photo.jpg", "relationship": "parentOf", "instance_id": ingredient_id},
2190
+ "image/jpeg", f)
2191
+ archive = io.BytesIO()
2192
+ archive_builder.write_ingredient_archive(ingredient_id, archive)
2193
+ archive_builder.close()
2194
+
2195
+ archive.seek(0)
2196
+ signing_builder = Builder.from_json(manifest)
2197
+ signing_builder.add_ingredient_from_archive(archive)
2198
+ archive.close()
2199
+
2200
+ output = io.BytesIO()
2201
+ with open(self.testPath, "rb") as f:
2202
+ signing_builder.sign(self.signer, "image/jpeg", f, output)
2203
+ signing_builder.close()
2204
+
2205
+ output.seek(0)
2206
+ reader = Reader("image/jpeg", output)
2207
+ json_data = reader.json()
2208
+ self.assertIn("c2pa.opened", json_data)
2209
+ self.assertIn(ingredient_id, json_data)
2210
+ output.close()
2211
+
2212
+ def test_ingredient_from_archive_linked_to_placed_action(self):
2213
+ ingredient_id = "xmp:iid:archive-component-001"
2214
+ manifest = {
2215
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2216
+ "assertions": [
2217
+ {
2218
+ "label": "c2pa.actions.v2",
2219
+ "data": {
2220
+ "actions": [
2221
+ {
2222
+ "action": "c2pa.placed",
2223
+ "parameters": {"ingredientIds": [ingredient_id]},
2224
+ }
2225
+ ]
2226
+ },
2227
+ }
2228
+ ],
2229
+ }
2230
+ archive_builder = Builder.from_json(manifest)
2231
+ with open(self.testPath, "rb") as f:
2232
+ archive_builder.add_ingredient(
2233
+ {"title": "photo.jpg", "relationship": "componentOf", "instance_id": ingredient_id},
2234
+ "image/jpeg", f)
2235
+ archive = io.BytesIO()
2236
+ archive_builder.write_ingredient_archive(ingredient_id, archive)
2237
+ archive_builder.close()
2238
+
2239
+ archive.seek(0)
2240
+ signing_builder = Builder.from_json(manifest)
2241
+ signing_builder.add_ingredient_from_archive(archive)
2242
+ archive.close()
2243
+
2244
+ output = io.BytesIO()
2245
+ with open(self.testPath, "rb") as f:
2246
+ signing_builder.sign(self.signer, "image/jpeg", f, output)
2247
+ signing_builder.close()
2248
+
2249
+ output.seek(0)
2250
+ reader = Reader("image/jpeg", output)
2251
+ json_data = reader.json()
2252
+ self.assertIn("c2pa.placed", json_data)
2253
+ self.assertIn(ingredient_id, json_data)
2254
+ output.close()
2255
+
2256
+ def test_ingredient_from_archive_linked_to_edited_action(self):
2257
+ ingredient_id = "xmp:iid:archive-input-001"
2258
+ manifest = {
2259
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2260
+ "assertions": [
2261
+ {
2262
+ "label": "c2pa.actions.v2",
2263
+ "data": {
2264
+ "actions": [
2265
+ {
2266
+ "action": "c2pa.edited",
2267
+ "parameters": {"ingredientIds": [ingredient_id]},
2268
+ }
2269
+ ]
2270
+ },
2271
+ }
2272
+ ],
2273
+ }
2274
+ archive_builder = Builder.from_json(manifest)
2275
+ with open(self.testPath, "rb") as f:
2276
+ archive_builder.add_ingredient(
2277
+ {"title": "photo.jpg", "relationship": "inputTo", "instance_id": ingredient_id},
2278
+ "image/jpeg", f)
2279
+ archive = io.BytesIO()
2280
+ archive_builder.write_ingredient_archive(ingredient_id, archive)
2281
+ archive_builder.close()
2282
+
2283
+ archive.seek(0)
2284
+ signing_builder = Builder.from_json(manifest)
2285
+ signing_builder.add_ingredient_from_archive(archive)
2286
+ archive.close()
2287
+
2288
+ output = io.BytesIO()
2289
+ with open(self.testPath, "rb") as f:
2290
+ signing_builder.sign(self.signer, "image/jpeg", f, output)
2291
+ signing_builder.close()
2292
+
2293
+ output.seek(0)
2294
+ reader = Reader("image/jpeg", output)
2295
+ json_data = reader.json()
2296
+ self.assertIn("c2pa.edited", json_data)
2297
+ self.assertIn(ingredient_id, json_data)
2298
+ output.close()
2299
+
2300
+ def test_add_two_ingredient_archives_to_one_builder(self):
2301
+ manifest = {
2302
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2303
+ "assertions": [],
2304
+ }
2305
+ archives = []
2306
+ for title, instance_id in [("A.jpg", "ingredient-A"), ("B.jpg", "ingredient-B")]:
2307
+ archive_builder = Builder.from_json(manifest)
2308
+ ingredient_json = {
2309
+ "title": title,
2310
+ "relationship": "componentOf",
2311
+ "instance_id": instance_id,
2312
+ }
2313
+ with open(self.testPath, "rb") as f:
2314
+ archive_builder.add_ingredient(ingredient_json, "image/jpeg", f)
2315
+ archive = io.BytesIO()
2316
+ archive_builder.write_ingredient_archive(instance_id, archive)
2317
+ archive_builder.close()
2318
+ archive.seek(0)
2319
+ archives.append(archive)
2320
+
2321
+ signing_builder = Builder.from_json(manifest)
2322
+ for archive in archives:
2323
+ signing_builder.add_ingredient_from_archive(archive)
2324
+ archive.close()
2325
+
2326
+ output = io.BytesIO()
2327
+ with open(self.testPath, "rb") as f:
2328
+ signing_builder.sign(self.signer, "image/jpeg", f, output)
2329
+ signing_builder.close()
2330
+
2331
+ output.seek(0)
2332
+ reader = Reader("image/jpeg", output)
2333
+ json_data = reader.json()
2334
+ self.assertIn("A.jpg", json_data)
2335
+ self.assertIn("B.jpg", json_data)
2336
+ output.close()
2337
+
2338
+ def test_write_ingredient_archive_only_contains_requested_ingredient(self):
2339
+ manifest = {
2340
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2341
+ "assertions": [],
2342
+ }
2343
+ archive_builder = Builder.from_json(manifest)
2344
+ for title, instance_id in [("A.jpg", "ingredient-A"), ("B.jpg", "ingredient-B")]:
2345
+ ingredient_json = {
2346
+ "title": title,
2347
+ "relationship": "componentOf",
2348
+ "instance_id": instance_id,
2349
+ }
2350
+ with open(self.testPath, "rb") as f:
2351
+ archive_builder.add_ingredient(ingredient_json, "image/jpeg", f)
2352
+ archive = io.BytesIO()
2353
+ archive_builder.write_ingredient_archive("ingredient-A", archive)
2354
+ archive_builder.close()
2355
+
2356
+ archive.seek(0)
2357
+ signing_builder = Builder.from_json(manifest)
2358
+ signing_builder.add_ingredient_from_archive(archive)
2359
+ archive.close()
2360
+
2361
+ output = io.BytesIO()
2362
+ with open(self.testPath, "rb") as f:
2363
+ signing_builder.sign(self.signer, "image/jpeg", f, output)
2364
+ signing_builder.close()
2365
+
2366
+ output.seek(0)
2367
+ reader = Reader("image/jpeg", output)
2368
+ json_data = reader.json()
2369
+ self.assertIn("A.jpg", json_data)
2370
+ self.assertNotIn("B.jpg", json_data)
2371
+ output.close()
2372
+
2373
+ def test_write_ingredient_archive_matches_label_or_instance_id(self):
2374
+ manifest = {
2375
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2376
+ "assertions": [],
2377
+ }
2378
+ ingredient_json = {
2379
+ "title": "photo.jpg",
2380
+ "relationship": "componentOf",
2381
+ "label": "my-label",
2382
+ "instance_id": "my-instance-id",
2383
+ }
2384
+ builder = Builder.from_json(manifest)
2385
+ with open(self.testPath, "rb") as f:
2386
+ builder.add_ingredient(ingredient_json, "image/jpeg", f)
2387
+
2388
+ by_label = io.BytesIO()
2389
+ builder.write_ingredient_archive("my-label", by_label)
2390
+ self.assertGreater(len(by_label.getvalue()), 0)
2391
+ by_label.close()
2392
+
2393
+ by_instance_id = io.BytesIO()
2394
+ builder.write_ingredient_archive("my-instance-id", by_instance_id)
2395
+ self.assertGreater(len(by_instance_id.getvalue()), 0)
2396
+ by_instance_id.close()
2397
+
2398
+ builder.close()
2399
+
2400
+ def test_write_ingredient_archive_unknown_id_raises(self):
2401
+ manifest = {
2402
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2403
+ "assertions": [],
2404
+ }
2405
+ builder = Builder.from_json(manifest)
2406
+ ingredient_json = {
2407
+ "title": "photo.jpg",
2408
+ "relationship": "componentOf",
2409
+ "instance_id": "ingredient-A",
2410
+ }
2411
+ with open(self.testPath, "rb") as f:
2412
+ builder.add_ingredient(ingredient_json, "image/jpeg", f)
2413
+ with self.assertRaises(Error):
2414
+ builder.write_ingredient_archive("nonexistent-id", io.BytesIO())
2415
+ builder.close()
2416
+
2417
+ def test_add_ingredient_from_archive_invalid_data_raises(self):
2418
+ manifest = {
2419
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2420
+ "assertions": [],
2421
+ }
2422
+ builder = Builder.from_json(manifest)
2423
+ with self.assertRaises(Error):
2424
+ builder.add_ingredient_from_archive(io.BytesIO(b"not a c2pa archive"))
2425
+ with self.assertRaises(Error):
2426
+ builder.add_ingredient_from_archive(io.BytesIO())
2427
+ builder.close()
2428
+
2429
+ def test_ingredient_archive_methods_raise_on_closed_builder(self):
2430
+ manifest = {
2431
+ "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}],
2432
+ "assertions": [],
2433
+ }
2434
+ builder = Builder.from_json(manifest)
2435
+ builder.close()
2436
+ with self.assertRaises(Error):
2437
+ builder.write_ingredient_archive("ingredient-A", io.BytesIO())
2438
+ with self.assertRaises(Error):
2439
+ builder.add_ingredient_from_archive(io.BytesIO())
2440
+
1948
2441
  def test_remote_sign(self):
1949
2442
  with open(self.testPath, "rb") as file:
1950
2443
  builder = Builder(self.manifestDefinition)
@@ -5548,6 +6041,119 @@ class TestReaderWithContext(TestContextAPIs):
5548
6041
  context.close()
5549
6042
 
5550
6043
 
6044
+ class TestSidecarReader(TestContextAPIs):
6045
+ """Reader over a detached (sidecar) manifest with a Context"""
6046
+
6047
+ def _make_detached_manifest(self):
6048
+ """Sign DEFAULT_TEST_FILE with no-embed, return (asset_bytes,
6049
+ manifest_bytes) where manifest_bytes is the "detached" sidecar
6050
+ C2PA manifest."""
6051
+
6052
+ signer = self._ctx_make_signer()
6053
+ with open(DEFAULT_TEST_FILE, "rb") as f:
6054
+ asset_bytes = f.read()
6055
+ builder = Builder(self.test_manifest)
6056
+ builder.set_no_embed()
6057
+ # Output is discarded: with no-embed the asset is unchanged and the
6058
+ # manifest is returned by sign().
6059
+ manifest_bytes = builder.sign(
6060
+ signer, "image/jpeg", io.BytesIO(asset_bytes), io.BytesIO())
6061
+ builder.close()
6062
+ signer.close()
6063
+ self.assertIsInstance(manifest_bytes, bytes)
6064
+ self.assertGreater(len(manifest_bytes), 0)
6065
+ return asset_bytes, manifest_bytes
6066
+
6067
+ def test_reader_with_manifest_data_and_context(self):
6068
+ asset_bytes, manifest_bytes = self._make_detached_manifest()
6069
+ context = Context()
6070
+ reader = Reader(
6071
+ "image/jpeg",
6072
+ io.BytesIO(asset_bytes),
6073
+ manifest_bytes,
6074
+ context=context,
6075
+ )
6076
+ try:
6077
+ data = reader.json()
6078
+ self.assertTrue(data)
6079
+ self.assertFalse(reader.is_embedded())
6080
+ self.assertIn("manifests", json.loads(data))
6081
+ finally:
6082
+ reader.close()
6083
+ context.close()
6084
+
6085
+ def test_reader_manifest_data_context_invalid_manifest_raises(self):
6086
+ with open(DEFAULT_TEST_FILE, "rb") as f:
6087
+ asset_bytes = f.read()
6088
+ context = Context()
6089
+ reader = None
6090
+ try:
6091
+ with self.assertRaises(Error):
6092
+ reader = Reader(
6093
+ "image/jpeg",
6094
+ io.BytesIO(asset_bytes),
6095
+ b"not a real manifest",
6096
+ context=context,
6097
+ )
6098
+ # The consumed-pointer error branch must leave no usable handle.
6099
+ if reader is not None:
6100
+ self.assertEqual(
6101
+ reader._lifecycle_state, LifecycleState.CLOSED)
6102
+ self.assertIsNone(reader._handle)
6103
+ finally:
6104
+ if reader is not None:
6105
+ reader.close()
6106
+ context.close()
6107
+
6108
+ def test_reader_manifest_data_context_wrong_type_raises(self):
6109
+ with open(DEFAULT_TEST_FILE, "rb") as f:
6110
+ asset_bytes = f.read()
6111
+ context = Context()
6112
+ try:
6113
+ # Non-bytes manifest_data must raise TypeError before any native
6114
+ # reader handle is allocated (no leak on this path).
6115
+ with self.assertRaises(TypeError):
6116
+ Reader(
6117
+ "image/jpeg",
6118
+ io.BytesIO(asset_bytes),
6119
+ "manifest as str, not bytes",
6120
+ context=context,
6121
+ )
6122
+ finally:
6123
+ context.close()
6124
+
6125
+ def test_reader_manifest_data_context_use_after_close_raises(self):
6126
+ asset_bytes, manifest_bytes = self._make_detached_manifest()
6127
+ context = Context()
6128
+ reader = Reader(
6129
+ "image/jpeg",
6130
+ io.BytesIO(asset_bytes),
6131
+ manifest_bytes,
6132
+ context=context,
6133
+ )
6134
+ reader.close()
6135
+ self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED)
6136
+ with self.assertRaises(Error):
6137
+ reader.json()
6138
+ # Idempotent close after use-after-close attempt.
6139
+ reader.close()
6140
+ context.close()
6141
+
6142
+ def test_reader_manifest_data_context_as_context_manager(self):
6143
+ asset_bytes, manifest_bytes = self._make_detached_manifest()
6144
+ context = Context()
6145
+ with Reader(
6146
+ "image/jpeg",
6147
+ io.BytesIO(asset_bytes),
6148
+ manifest_bytes,
6149
+ context=context,
6150
+ ) as reader:
6151
+ self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE)
6152
+ self.assertTrue(reader.json())
6153
+ self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED)
6154
+ context.close()
6155
+
6156
+
5551
6157
  class TestBuilderWithContext(TestContextAPIs):
5552
6158
 
5553
6159
  def test_contextual_builder_with_default_context(self):
@@ -6255,5 +6861,68 @@ class TestContextIntegration(TestContextAPIs):
6255
6861
  context.close()
6256
6862
 
6257
6863
 
6864
+ class TestStreamReferences(unittest.TestCase):
6865
+
6866
+ def test_stream_collected_after_del(self):
6867
+ """Stream must be collected by reference counting."""
6868
+ import gc
6869
+ import weakref
6870
+ gc.collect()
6871
+ garbage_before = len(gc.garbage)
6872
+
6873
+ buf = io.BytesIO(b"hello world")
6874
+ s = Stream(buf)
6875
+ ref = weakref.ref(s)
6876
+ del s
6877
+
6878
+ # Trigger gc, we want to verify it's collected
6879
+ # collected now (1 gc call) means no gc cycle breaker needed
6880
+ # aka ref cycle did not happen.
6881
+ gc.collect()
6882
+
6883
+ self.assertIsNone(ref(), "Stream not collected")
6884
+ self.assertEqual(len(gc.garbage), garbage_before,
6885
+ "Stream added objects to gc.garbage")
6886
+
6887
+ def test_stream_not_added_to_gc_garbage_list(self):
6888
+ """Creating and dropping many Streams must not grow gc.garbage."""
6889
+ import gc
6890
+ gc.collect()
6891
+ gc.garbage.clear()
6892
+
6893
+ for _ in range(20):
6894
+ s = Stream(io.BytesIO(b"data"))
6895
+ del s
6896
+
6897
+ gc.collect()
6898
+ self.assertEqual(len(gc.garbage), 0,
6899
+ f"gc.garbage unexpectedly non-empty: {gc.garbage}")
6900
+
6901
+ def test_callbacks_return_minus_one_after_stream_collected(self):
6902
+ """Callbacks must return -1 gracefully when the Stream has been GC'd."""
6903
+ import gc
6904
+ import weakref
6905
+
6906
+ buf = io.BytesIO(b"test")
6907
+ s = Stream(buf)
6908
+
6909
+ read_cb = s._read_cb
6910
+ seek_cb = s._seek_cb
6911
+ write_cb = s._write_cb
6912
+ flush_cb = s._flush_cb
6913
+ ref = weakref.ref(s)
6914
+ del s
6915
+ gc.collect()
6916
+
6917
+ # Stream should be gone.
6918
+ self.assertIsNone(ref(), "Stream not collected before callback test")
6919
+
6920
+ # All callbacks must return -1 without crashing.
6921
+ self.assertEqual(read_cb(None, None, 0), -1)
6922
+ self.assertEqual(seek_cb(None, 0, 0), -1)
6923
+ self.assertEqual(write_cb(None, None, 0), -1)
6924
+ self.assertEqual(flush_cb(None), -1)
6925
+
6926
+
6258
6927
  if __name__ == '__main__':
6259
6928
  unittest.main(warnings='ignore')
File without changes
File without changes
File without changes
File without changes
File without changes