c2pa-python 0.34.0__tar.gz → 0.35.1__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.34.0/src/c2pa_python.egg-info → c2pa_python-0.35.1}/PKG-INFO +2 -2
  2. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/pyproject.toml +3 -2
  3. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa/c2pa.py +97 -9
  4. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa/lib.py +25 -0
  5. {c2pa_python-0.34.0 → c2pa_python-0.35.1/src/c2pa_python.egg-info}/PKG-INFO +2 -2
  6. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/tests/test_unit_tests.py +557 -1
  7. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/tests/test_unit_tests_threaded.py +161 -1
  8. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/LICENSE-APACHE +0 -0
  9. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/LICENSE-MIT +0 -0
  10. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/MANIFEST.in +0 -0
  11. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/README.md +0 -0
  12. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/requirements.txt +0 -0
  13. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/scripts/download_artifacts.py +0 -0
  14. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/setup.cfg +0 -0
  15. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/setup.py +0 -0
  16. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa/__init__.py +0 -0
  17. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa/build.py +0 -0
  18. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa_python.egg-info/SOURCES.txt +0 -0
  19. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa_python.egg-info/dependency_links.txt +0 -0
  20. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa_python.egg-info/entry_points.txt +0 -0
  21. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa_python.egg-info/requires.txt +0 -0
  22. {c2pa_python-0.34.0 → c2pa_python-0.35.1}/src/c2pa_python.egg-info/top_level.txt +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.34.0
3
+ Version: 0.35.1
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.34.0"
7
+ version = "0.35.1"
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,11 +18,12 @@ 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
24
25
  import io
25
- from .lib import dynamically_load_library
26
+ from .lib import dynamically_load_library, record_owner_pid, is_foreign_process
26
27
  import mimetypes
27
28
  from itertools import count
28
29
 
@@ -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',
@@ -238,6 +241,7 @@ class ManagedResource:
238
241
  self._lifecycle_state = LifecycleState.UNINITIALIZED
239
242
  self._handle = None
240
243
  _clear_error_state()
244
+ record_owner_pid(self)
241
245
 
242
246
  @staticmethod
243
247
  def _free_native_ptr(ptr):
@@ -280,6 +284,8 @@ class ManagedResource:
280
284
  def _cleanup_resources(self):
281
285
  """Release native resources idempotently."""
282
286
  try:
287
+ if is_foreign_process(self):
288
+ return
283
289
  if (
284
290
  hasattr(self, '_lifecycle_state')
285
291
  and self._lifecycle_state != LifecycleState.CLOSED
@@ -620,6 +626,15 @@ _setup_function(_lib.c2pa_builder_add_action,
620
626
  _setup_function(_lib.c2pa_builder_to_archive,
621
627
  [ctypes.POINTER(C2paBuilder), ctypes.POINTER(C2paStream)],
622
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)
623
638
  _setup_function(_lib.c2pa_builder_sign,
624
639
  [ctypes.POINTER(C2paBuilder),
625
640
  ctypes.c_char_p,
@@ -1611,6 +1626,10 @@ class Stream:
1611
1626
 
1612
1627
  self._file_like_stream = file_like_stream
1613
1628
 
1629
+ # Weakref breaks avoids references cycles:
1630
+ # Stream -> ctypes_cb -> closure -> Stream.
1631
+ _weak_self = weakref.ref(self)
1632
+
1614
1633
  def read_callback(ctx, data, length):
1615
1634
  """Callback function for reading data from the Python stream.
1616
1635
 
@@ -1629,13 +1648,16 @@ class Stream:
1629
1648
  Returns:
1630
1649
  Number of bytes read, or -1 on error
1631
1650
  """
1632
- 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:
1633
1655
  return -1
1634
1656
  try:
1635
1657
  if not data or length <= 0:
1636
1658
  return -1
1637
1659
 
1638
- stream = self._file_like_stream
1660
+ stream = s._file_like_stream
1639
1661
  readinto = getattr(stream, "readinto", None)
1640
1662
  if readinto is not None:
1641
1663
  # Most streams have readinto
@@ -1686,8 +1708,11 @@ class Stream:
1686
1708
  Returns:
1687
1709
  New position in the stream, or -1 on error
1688
1710
  """
1689
- file_stream = self._file_like_stream
1690
- 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:
1691
1716
  return -1
1692
1717
  try:
1693
1718
  # Fall back to tell() only for stream objects that do not
@@ -1715,13 +1740,16 @@ class Stream:
1715
1740
  Returns:
1716
1741
  Number of bytes written, or -1 on error
1717
1742
  """
1718
- 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:
1719
1747
  return -1
1720
1748
  try:
1721
1749
  if not data or length <= 0:
1722
1750
  return -1
1723
1751
 
1724
- self._file_like_stream.write(ctypes.string_at(data, length))
1752
+ s._file_like_stream.write(ctypes.string_at(data, length))
1725
1753
  return length
1726
1754
  except Exception:
1727
1755
  return -1
@@ -1740,10 +1768,13 @@ class Stream:
1740
1768
  Returns:
1741
1769
  0 on success, -1 on error
1742
1770
  """
1743
- 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:
1744
1775
  return -1
1745
1776
  try:
1746
- self._file_like_stream.flush()
1777
+ s._file_like_stream.flush()
1747
1778
  return 0
1748
1779
  except Exception:
1749
1780
  return -1
@@ -1768,6 +1799,7 @@ class Stream:
1768
1799
  raise Exception("Failed to create stream: {}".format(error))
1769
1800
 
1770
1801
  self._initialized = True
1802
+ record_owner_pid(self)
1771
1803
 
1772
1804
  def __enter__(self):
1773
1805
  """Context manager entry."""
@@ -1786,6 +1818,8 @@ class Stream:
1786
1818
  hasn't been explicitly closed.
1787
1819
  """
1788
1820
  try:
1821
+ if is_foreign_process(self):
1822
+ return
1789
1823
  # Only cleanup if not already closed and we have a valid stream
1790
1824
  if hasattr(self, '_closed') and not self._closed:
1791
1825
  stream = self._stream
@@ -1816,6 +1850,10 @@ class Stream:
1816
1850
  """
1817
1851
  if self._closed:
1818
1852
  return
1853
+ if is_foreign_process(self):
1854
+ self._closed = True
1855
+ self._initialized = False
1856
+ return
1819
1857
 
1820
1858
  try:
1821
1859
  # Clean up stream first as it depends on callbacks
@@ -2888,6 +2926,7 @@ class Builder(ManagedResource):
2888
2926
  'url_error': "Error setting remote URL: {}",
2889
2927
  'resource_error': "Error adding resource: {}",
2890
2928
  'ingredient_error': "Error adding ingredient: {}",
2929
+ 'archive_read_error': "Error loading ingredient from archive: {}",
2891
2930
  'action_error': "Error adding action: {}",
2892
2931
  'archive_error': "Error writing archive: {}",
2893
2932
  'sign_error': "Error during signing: {}",
@@ -3277,6 +3316,55 @@ class Builder(ManagedResource):
3277
3316
  Builder._ERROR_MESSAGES["archive_error"].format("Unknown error"),
3278
3317
  check=lambda r: r != 0)
3279
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
+
3280
3368
  def with_archive(self, stream: Any) -> 'Builder':
3281
3369
  """Load an archive into this Builder instance, replacing its
3282
3370
  manifest definition. The archive carries only the
@@ -299,3 +299,28 @@ def dynamically_load_library(
299
299
  raise RuntimeError(f"Could not find {c2pa_lib_name} in any of the search paths")
300
300
 
301
301
  return c2pa_lib
302
+
303
+
304
+ def record_owner_pid(obj):
305
+ """Keep the PID that created this native-handle wrapper
306
+ (call from __init__ as needed).
307
+ """
308
+ obj._owner_pid = os.getpid()
309
+
310
+
311
+ def is_foreign_process(obj):
312
+ """Return True when this object is being finalized in a forked child that did not
313
+ create it. After a multithreaded fork(), native mutexes may be held by threads
314
+ absent in the child -> any lock() call deadlocks. Callers must skip native frees
315
+ when this returns True.
316
+
317
+ Skipping the free does not cause a cumulative leak. If the child calls exec() the
318
+ address space is replaced entirely; if it exits, the OS reclaims all process memory.
319
+ Even a long-lived fork child (e.g. a multiprocessing fork-start worker) leaks at most
320
+ the objects inherited at fork time — a one-time, bounded amount reclaimed when the
321
+ child terminates. Objects the child creates itself carry the child's PID and are
322
+ freed normally.
323
+
324
+ Defensive default: if _owner_pid was never set, returns False (no regression)."""
325
+ owner = getattr(obj, '_owner_pid', None)
326
+ return owner is not None and owner != os.getpid()
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.34.0
3
+ Version: 0.35.1
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)
@@ -6255,5 +6748,68 @@ class TestContextIntegration(TestContextAPIs):
6255
6748
  context.close()
6256
6749
 
6257
6750
 
6751
+ class TestStreamReferences(unittest.TestCase):
6752
+
6753
+ def test_stream_collected_after_del(self):
6754
+ """Stream must be collected by reference counting."""
6755
+ import gc
6756
+ import weakref
6757
+ gc.collect()
6758
+ garbage_before = len(gc.garbage)
6759
+
6760
+ buf = io.BytesIO(b"hello world")
6761
+ s = Stream(buf)
6762
+ ref = weakref.ref(s)
6763
+ del s
6764
+
6765
+ # Trigger gc, we want to verify it's collected
6766
+ # collected now (1 gc call) means no gc cycle breaker needed
6767
+ # aka ref cycle did not happen.
6768
+ gc.collect()
6769
+
6770
+ self.assertIsNone(ref(), "Stream not collected")
6771
+ self.assertEqual(len(gc.garbage), garbage_before,
6772
+ "Stream added objects to gc.garbage")
6773
+
6774
+ def test_stream_not_added_to_gc_garbage_list(self):
6775
+ """Creating and dropping many Streams must not grow gc.garbage."""
6776
+ import gc
6777
+ gc.collect()
6778
+ gc.garbage.clear()
6779
+
6780
+ for _ in range(20):
6781
+ s = Stream(io.BytesIO(b"data"))
6782
+ del s
6783
+
6784
+ gc.collect()
6785
+ self.assertEqual(len(gc.garbage), 0,
6786
+ f"gc.garbage unexpectedly non-empty: {gc.garbage}")
6787
+
6788
+ def test_callbacks_return_minus_one_after_stream_collected(self):
6789
+ """Callbacks must return -1 gracefully when the Stream has been GC'd."""
6790
+ import gc
6791
+ import weakref
6792
+
6793
+ buf = io.BytesIO(b"test")
6794
+ s = Stream(buf)
6795
+
6796
+ read_cb = s._read_cb
6797
+ seek_cb = s._seek_cb
6798
+ write_cb = s._write_cb
6799
+ flush_cb = s._flush_cb
6800
+ ref = weakref.ref(s)
6801
+ del s
6802
+ gc.collect()
6803
+
6804
+ # Stream should be gone.
6805
+ self.assertIsNone(ref(), "Stream not collected before callback test")
6806
+
6807
+ # All callbacks must return -1 without crashing.
6808
+ self.assertEqual(read_cb(None, None, 0), -1)
6809
+ self.assertEqual(seek_cb(None, 0, 0), -1)
6810
+ self.assertEqual(write_cb(None, None, 0), -1)
6811
+ self.assertEqual(flush_cb(None), -1)
6812
+
6813
+
6258
6814
  if __name__ == '__main__':
6259
6815
  unittest.main(warnings='ignore')
@@ -11,6 +11,7 @@
11
11
  # specific language governing permissions and limitations under
12
12
  # each license.
13
13
 
14
+ import ctypes
14
15
  import os
15
16
  import io
16
17
  import json
@@ -20,13 +21,172 @@ import concurrent.futures
20
21
  import time
21
22
  import asyncio
22
23
  import random
24
+ from unittest.mock import MagicMock, patch
23
25
 
24
26
  from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version # noqa: E501
25
27
  from c2pa import Context, Settings
26
- from c2pa.c2pa import Stream
28
+ from c2pa.c2pa import ManagedResource, Stream, LifecycleState
29
+ from c2pa.lib import is_foreign_process, record_owner_pid
27
30
 
28
31
  PROJECT_PATH = os.getcwd()
29
32
  FIXTURES_FOLDER = os.path.join(os.path.dirname(__file__), "fixtures")
33
+
34
+
35
+ class _ConcreteResource(ManagedResource):
36
+ """Minimal concrete subclass for testing ManagedResource cleanup."""
37
+
38
+
39
+ def _make_resource(pid_offset):
40
+ """Construct a ManagedResource-like object without triggering native init.
41
+
42
+ pid_offset=1 → simulates a forked child (foreign PID)
43
+ pid_offset=0 → same process (normal cleanup)
44
+ pid_offset=None → no _owner_pid stamp (backward-compat: no protection)
45
+ """
46
+ obj = object.__new__(_ConcreteResource)
47
+ obj._lifecycle_state = LifecycleState.ACTIVE
48
+ obj._handle = ctypes.c_void_p(1) # non-None, non-zero sentinel
49
+ if pid_offset is not None:
50
+ obj._owner_pid = os.getpid() + pid_offset
51
+ return obj
52
+
53
+
54
+ def _make_stream(pid_offset):
55
+ """Construct a Stream-like object without triggering native init."""
56
+ obj = object.__new__(Stream)
57
+ obj._closed = False
58
+ obj._initialized = True
59
+ obj._stream = MagicMock() # non-None stream handle
60
+ if pid_offset is not None:
61
+ obj._owner_pid = os.getpid() + pid_offset
62
+ return obj
63
+
64
+
65
+ class TestManagedResourceForkGuard(unittest.TestCase):
66
+ """Fork-safety unit tests for ManagedResource and Stream.
67
+
68
+ Verifies that the is_foreign_process() PID guard prevents native frees
69
+ from running in a forked child process (where native mutexes may be held
70
+ by threads that no longer exist, causing deadlock before exec()).
71
+
72
+ No real fork or auth credentials are required; PID mismatch is simulated
73
+ by setting _owner_pid = os.getpid() + 1.
74
+ """
75
+
76
+ def test_foreign_pid_skips_free(self):
77
+ """In a forked child (pid_offset=1), no native free should run."""
78
+ obj = _make_resource(pid_offset=1)
79
+ with patch('c2pa.c2pa._lib') as mock_lib:
80
+ obj._cleanup_resources()
81
+ mock_lib.c2pa_free.assert_not_called()
82
+
83
+ def test_own_pid_calls_free(self):
84
+ """In the owning process, cleanup must call c2pa_free normally."""
85
+ obj = _make_resource(pid_offset=0)
86
+ expected_handle = obj._handle
87
+ with patch('c2pa.c2pa._lib'):
88
+ with patch.object(ManagedResource, '_free_native_ptr') as mock_free:
89
+ obj._cleanup_resources()
90
+ mock_free.assert_called_once_with(expected_handle)
91
+
92
+ def test_no_stamp_calls_free(self):
93
+ """No _owner_pid (backward-compat) must NOT suppress cleanup."""
94
+ obj = _make_resource(pid_offset=None)
95
+ with patch.object(ManagedResource, '_free_native_ptr') as mock_free:
96
+ obj._cleanup_resources()
97
+ mock_free.assert_called_once()
98
+
99
+ def test_foreign_pid_leaves_state_unchanged(self):
100
+ """Guard returns early; lifecycle state stays ACTIVE (not CLOSED)."""
101
+ obj = _make_resource(pid_offset=1)
102
+ with patch('c2pa.c2pa._lib'):
103
+ obj._cleanup_resources()
104
+ self.assertEqual(obj._lifecycle_state, LifecycleState.ACTIVE)
105
+
106
+ def test_double_cleanup_is_idempotent(self):
107
+ """Second call is a no-op after successful first cleanup."""
108
+ obj = _make_resource(pid_offset=0)
109
+ with patch.object(ManagedResource, '_free_native_ptr') as mock_free:
110
+ obj._cleanup_resources()
111
+ obj._cleanup_resources()
112
+ mock_free.assert_called_once()
113
+
114
+ def test_foreign_pid_skips_release_via_del(self):
115
+ obj = _make_stream(pid_offset=1)
116
+ with patch('c2pa.c2pa._lib') as mock_lib:
117
+ obj.__del__()
118
+ mock_lib.c2pa_release_stream.assert_not_called()
119
+
120
+ def test_own_pid_releases_stream_via_del(self):
121
+ obj = _make_stream(pid_offset=0)
122
+ with patch('c2pa.c2pa._lib') as mock_lib:
123
+ obj.__del__()
124
+ mock_lib.c2pa_release_stream.assert_called_once()
125
+
126
+ def test_no_stamp_releases_stream_via_del(self):
127
+ obj = _make_stream(pid_offset=None)
128
+ with patch('c2pa.c2pa._lib') as mock_lib:
129
+ obj.__del__()
130
+ mock_lib.c2pa_release_stream.assert_called_once()
131
+
132
+ def test_already_closed_is_noop_via_del(self):
133
+ obj = _make_stream(pid_offset=0)
134
+ obj._closed = True
135
+ with patch('c2pa.c2pa._lib') as mock_lib:
136
+ obj.__del__()
137
+ mock_lib.c2pa_release_stream.assert_not_called()
138
+
139
+ def test_foreign_pid_skips_release_via_close(self):
140
+ obj = _make_stream(pid_offset=1)
141
+ with patch('c2pa.c2pa._lib') as mock_lib:
142
+ obj.close()
143
+ mock_lib.c2pa_release_stream.assert_not_called()
144
+ self.assertTrue(obj._closed)
145
+
146
+ def test_own_pid_releases_stream_via_close(self):
147
+ obj = _make_stream(pid_offset=0)
148
+ with patch('c2pa.c2pa._lib') as mock_lib:
149
+ obj.close()
150
+ mock_lib.c2pa_release_stream.assert_called_once()
151
+
152
+ def test_no_stamp_releases_stream_via_close(self):
153
+ obj = _make_stream(pid_offset=None)
154
+ with patch('c2pa.c2pa._lib') as mock_lib:
155
+ obj.close()
156
+ mock_lib.c2pa_release_stream.assert_called_once()
157
+
158
+ def test_already_closed_is_noop_via_close(self):
159
+ obj = _make_stream(pid_offset=0)
160
+ obj._closed = True
161
+ with patch('c2pa.c2pa._lib') as mock_lib:
162
+ obj.close()
163
+ mock_lib.c2pa_release_stream.assert_not_called()
164
+
165
+ def test_foreign_pid_close_marks_closed(self):
166
+ """close() in forked child must set _closed=True to prevent re-entry,
167
+ and _initialized=False so the public properties report a closed stream."""
168
+ obj = _make_stream(pid_offset=1)
169
+ with patch('c2pa.c2pa._lib'):
170
+ obj.close()
171
+ self.assertTrue(obj._closed)
172
+ self.assertFalse(obj._initialized)
173
+
174
+
175
+ class TestHelpers(unittest.TestCase):
176
+
177
+ def test_record_and_detect_own_pid(self):
178
+ obj = MagicMock()
179
+ record_owner_pid(obj)
180
+ self.assertFalse(is_foreign_process(obj))
181
+
182
+ def test_detect_foreign_pid(self):
183
+ obj = MagicMock()
184
+ obj._owner_pid = os.getpid() + 1
185
+ self.assertTrue(is_foreign_process(obj))
186
+
187
+ def test_no_stamp_not_foreign(self):
188
+ obj = MagicMock(spec=[]) # no _owner_pid attribute
189
+ self.assertFalse(is_foreign_process(obj))
30
190
  DEFAULT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "C.jpg")
31
191
  INGREDIENT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "A.jpg")
32
192
  ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "cloud.jpg")
File without changes
File without changes
File without changes
File without changes
File without changes