c2pa-python 0.22.0__tar.gz → 0.23.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.22.0/src/c2pa_python.egg-info → c2pa_python-0.23.1}/PKG-INFO +6 -1
  2. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/README.md +5 -0
  3. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/pyproject.toml +1 -1
  4. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/src/c2pa/build.py +3 -4
  5. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/src/c2pa/c2pa.py +141 -119
  6. {c2pa_python-0.22.0 → c2pa_python-0.23.1/src/c2pa_python.egg-info}/PKG-INFO +6 -1
  7. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/tests/test_unit_tests.py +2 -2
  8. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/LICENSE-APACHE +0 -0
  9. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/LICENSE-MIT +0 -0
  10. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/MANIFEST.in +0 -0
  11. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/requirements.txt +0 -0
  12. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/scripts/download_artifacts.py +0 -0
  13. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/setup.cfg +0 -0
  14. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/setup.py +0 -0
  15. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/src/c2pa/__init__.py +0 -0
  16. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/src/c2pa/lib.py +0 -0
  17. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/src/c2pa_python.egg-info/SOURCES.txt +0 -0
  18. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/src/c2pa_python.egg-info/dependency_links.txt +0 -0
  19. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/src/c2pa_python.egg-info/entry_points.txt +0 -0
  20. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/src/c2pa_python.egg-info/requires.txt +0 -0
  21. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/src/c2pa_python.egg-info/top_level.txt +0 -0
  22. {c2pa_python-0.22.0 → c2pa_python-0.23.1}/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.22.0
3
+ Version: 0.23.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
6
  Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
@@ -56,9 +56,14 @@ import c2pa
56
56
  ## Examples
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/sign.py` shows how to sign and verify an asset with a C2PA manifest.
60
61
  - `examples/training.py` demonstrates how to add a "Do Not Train" assertion to an asset and verify it.
61
62
 
63
+ ## API reference documentation
64
+
65
+ See [the section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation).
66
+
62
67
  ## Contributing
63
68
 
64
69
  Contributions are welcome! For more information, see [Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md).
@@ -33,9 +33,14 @@ import c2pa
33
33
  ## Examples
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/sign.py` shows how to sign and verify an asset with a C2PA manifest.
37
38
  - `examples/training.py` demonstrates how to add a "Do Not Train" assertion to an asset and verify it.
38
39
 
40
+ ## API reference documentation
41
+
42
+ See [the section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation).
43
+
39
44
  ## Contributing
40
45
 
41
46
  Contributions are welcome! For more information, see [Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "c2pa-python"
7
- version = "0.22.0"
7
+ version = "0.23.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" }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import os
15
15
  import sys
16
- import requests
16
+ import requests # type: ignore
17
17
  from pathlib import Path
18
18
  import zipfile
19
19
  import io
@@ -53,8 +53,7 @@ def download_artifact(url: str, platform_name: str) -> None:
53
53
  # Extract all files to the platform directory
54
54
  zip_ref.extractall(platform_dir)
55
55
 
56
- print(f"Successfully downloaded and extracted artifacts for {
57
- platform_name}")
56
+ print(f"Successfully downloaded and extracted artifacts for {platform_name}")
58
57
 
59
58
 
60
59
  def download_artifacts() -> None:
@@ -95,7 +94,7 @@ def download_artifacts() -> None:
95
94
  def inject_version():
96
95
  """Inject the version from pyproject.toml
97
96
  into src/c2pa/__init__.py as __version__."""
98
- import toml
97
+ import toml # type: ignore
99
98
  pyproject_path = os.path.abspath(
100
99
  os.path.join(
101
100
  os.path.dirname(__file__),
@@ -23,6 +23,7 @@ from typing import Optional, Union, Callable, Any, overload
23
23
  import io
24
24
  from .lib import dynamically_load_library
25
25
  import mimetypes
26
+ from itertools import count
26
27
 
27
28
  # Create a module-specific logger
28
29
  logger = logging.getLogger("c2pa")
@@ -567,7 +568,7 @@ def _convert_to_py_string(value) -> str:
567
568
  # Only if we got a valid pointer with valid content
568
569
  if ptr and ptr.value is not None:
569
570
  try:
570
- py_string = ptr.value.decode('utf-8', errors='replace')
571
+ py_string = ptr.value.decode('utf-8', errors='strict')
571
572
  except Exception:
572
573
  py_string = ""
573
574
  finally:
@@ -706,24 +707,14 @@ def _get_mime_type_from_path(path: Union[str, Path]) -> str:
706
707
 
707
708
  def read_ingredient_file(
708
709
  path: Union[str, Path], data_dir: Union[str, Path]) -> str:
709
- """Read a file as C2PA ingredient.
710
+ """Read a file as C2PA ingredient (deprecated).
710
711
  This creates the JSON string that would be used as the ingredient JSON.
711
712
 
712
713
  .. deprecated:: 0.11.0
713
714
  This function is deprecated and will be removed in a future version.
714
- Please use the Reader class for reading C2PA metadata instead.
715
- Example:
716
- ```python
717
- with Reader(path) as reader:
718
- manifest_json = reader.json()
719
- ```
720
-
721
- To add ingredients to a manifest, please use the Builder class.
722
- Example:
723
- ```
724
- with open(ingredient_file_path, 'rb') as f:
725
- builder.add_ingredient(ingredient_json, "image/jpeg", f)
726
- ```
715
+ To read C2PA metadata, use the :class:`c2pa.c2pa.Reader` class.
716
+ To add ingredients to a manifest,
717
+ use :meth:`c2pa.c2pa.Builder.add_ingredient` instead.
727
718
 
728
719
  Args:
729
720
  path: Path to the file to read
@@ -766,16 +757,11 @@ def read_ingredient_file(
766
757
 
767
758
  def read_file(path: Union[str, Path],
768
759
  data_dir: Union[str, Path]) -> str:
769
- """Read a C2PA manifest from a file.
760
+ """Read a C2PA manifest from a file (deprecated).
770
761
 
771
762
  .. deprecated:: 0.10.0
772
763
  This function is deprecated and will be removed in a future version.
773
- Please use the Reader class for reading C2PA metadata instead.
774
- Example:
775
- ```python
776
- with Reader(path) as reader:
777
- manifest_json = reader.json()
778
- ```
764
+ To read C2PA metadata, use the :class:`c2pa.c2pa.Reader` class.
779
765
 
780
766
  Args:
781
767
  path: Path to the file to read
@@ -845,7 +831,7 @@ def sign_file(
845
831
  signer_or_info: Union[C2paSignerInfo, 'Signer'],
846
832
  return_manifest_as_bytes: bool = False
847
833
  ) -> Union[str, bytes]:
848
- """Sign a file with a C2PA manifest.
834
+ """Sign a file with a C2PA manifest (deprecated).
849
835
  For now, this function is left here to provide a backwards-compatible API.
850
836
 
851
837
  .. deprecated:: 0.13.0
@@ -926,16 +912,11 @@ def sign_file(
926
912
 
927
913
 
928
914
  class Stream:
929
- # Class-level counter for generating unique stream IDs
930
- # (useful for tracing streams usage in debug)
931
- _next_stream_id = 0
915
+ # Class-level somewhat atomic counter for generating
916
+ # unique stream IDs (useful for tracing streams usage in debug)
917
+ _stream_id_counter = count(start=0, step=1)
918
+
932
919
  # Maximum value for a 32-bit signed integer (2^31 - 1)
933
- # This prevents integer overflow which could cause:
934
- # 1. Unexpected behavior in stream ID generation
935
- # 2. Potential security issues if IDs wrap around
936
- # 3. Memory issues if the number grows too large
937
- # When this limit is reached, we reset to 0 since the timestamp component
938
- # of the stream ID ensures uniqueness even after counter reset
939
920
  _MAX_STREAM_ID = 2**31 - 1
940
921
 
941
922
  # Class-level error messages to avoid multiple creation
@@ -973,10 +954,15 @@ class Stream:
973
954
  self._stream = None
974
955
 
975
956
  # Generate unique stream ID using object ID and counter
976
- if Stream._next_stream_id >= Stream._MAX_STREAM_ID: # pragma: no cover
977
- Stream._next_stream_id = 0
978
- self._stream_id = f"{id(self)}-{Stream._next_stream_id}"
979
- Stream._next_stream_id += 1
957
+ stream_counter = next(Stream._stream_id_counter)
958
+
959
+ # Handle counter overflow by resetting the counter
960
+ if stream_counter >= Stream._MAX_STREAM_ID: # pragma: no cover
961
+ # Reset the counter to 0 and get the next value
962
+ Stream._stream_id_counter = count(start=0, step=1)
963
+ stream_counter = next(Stream._stream_id_counter)
964
+
965
+ self._stream_id = f"{id(self)}-{stream_counter}"
980
966
 
981
967
  # Rest of the existing initialization code...
982
968
  required_methods = ['read', 'write', 'seek', 'tell', 'flush']
@@ -1125,7 +1111,7 @@ class Stream:
1125
1111
 
1126
1112
  # Create the stream
1127
1113
  self._stream = _lib.c2pa_create_stream(
1128
- None, # context
1114
+ None,
1129
1115
  self._read_cb,
1130
1116
  self._seek_cb,
1131
1117
  self._write_cb,
@@ -1242,7 +1228,15 @@ class Stream:
1242
1228
 
1243
1229
 
1244
1230
  class Reader:
1245
- """High-level wrapper for C2PA Reader operations."""
1231
+ """High-level wrapper for C2PA Reader operations.
1232
+
1233
+ Example:
1234
+ ```
1235
+ with Reader("image/jpeg", output) as reader:
1236
+ manifest_json = reader.json()
1237
+ ```
1238
+ Where `output` is either an in-memory stream or an opened file.
1239
+ """
1246
1240
 
1247
1241
  # Supported mimetypes cache
1248
1242
  _supported_mime_types_cache = None
@@ -1374,34 +1368,32 @@ class Reader:
1374
1368
  str(e)))
1375
1369
 
1376
1370
  try:
1377
- # Open the file and create a stream
1378
- file = open(path, 'rb')
1379
- self._own_stream = Stream(file)
1380
-
1381
- self._reader = _lib.c2pa_reader_from_stream(
1382
- mime_type_str,
1383
- self._own_stream._stream
1384
- )
1371
+ with open(path, 'rb') as file:
1372
+ self._own_stream = Stream(file)
1385
1373
 
1386
- if not self._reader:
1387
- self._own_stream.close()
1388
- file.close()
1389
- error = _parse_operation_result_for_error(
1390
- _lib.c2pa_error())
1391
- if error:
1392
- raise C2paError(error)
1393
- raise C2paError(
1394
- Reader._ERROR_MESSAGES['reader_error'].format(
1395
- "Unknown error"
1396
- )
1374
+ self._reader = _lib.c2pa_reader_from_stream(
1375
+ mime_type_str,
1376
+ self._own_stream._stream
1397
1377
  )
1398
1378
 
1399
- # Store the file to close it later
1400
- self._backing_file = file
1379
+ if not self._reader:
1380
+ self._own_stream.close()
1381
+ error = _parse_operation_result_for_error(
1382
+ _lib.c2pa_error())
1383
+ if error:
1384
+ raise C2paError(error)
1385
+ raise C2paError(
1386
+ Reader._ERROR_MESSAGES['reader_error'].format(
1387
+ "Unknown error"
1388
+ )
1389
+ )
1401
1390
 
1402
- self._initialized = True
1391
+ # Store the file to close it later
1392
+ self._backing_file = file
1393
+ self._initialized = True
1403
1394
 
1404
1395
  except Exception as e:
1396
+ # File automatically closed by context manager
1405
1397
  if self._own_stream:
1406
1398
  self._own_stream.close()
1407
1399
  if hasattr(self, '_backing_file') and self._backing_file:
@@ -1420,50 +1412,49 @@ class Reader:
1420
1412
  f"Reader does not support {format_or_path}")
1421
1413
 
1422
1414
  try:
1423
- file = open(stream, 'rb')
1424
- self._own_stream = Stream(file)
1425
-
1426
- format_str = str(format_or_path)
1427
- format_bytes = format_str.encode('utf-8')
1428
-
1429
- if manifest_data is None:
1430
- self._reader = _lib.c2pa_reader_from_stream(
1431
- format_bytes, self._own_stream._stream)
1432
- else:
1433
- if not isinstance(manifest_data, bytes):
1434
- raise TypeError(
1435
- Reader._ERROR_MESSAGES['manifest_error'])
1436
- manifest_array = (
1437
- ctypes.c_ubyte *
1438
- len(manifest_data))(
1439
- *
1440
- manifest_data)
1441
- self._reader = (
1442
- _lib.c2pa_reader_from_manifest_data_and_stream(
1443
- format_bytes,
1444
- self._own_stream._stream,
1445
- manifest_array,
1446
- len(manifest_data),
1415
+ with open(stream, 'rb') as file:
1416
+ self._own_stream = Stream(file)
1417
+
1418
+ format_str = str(format_or_path)
1419
+ format_bytes = format_str.encode('utf-8')
1420
+
1421
+ if manifest_data is None:
1422
+ self._reader = _lib.c2pa_reader_from_stream(
1423
+ format_bytes, self._own_stream._stream)
1424
+ else:
1425
+ if not isinstance(manifest_data, bytes):
1426
+ raise TypeError(
1427
+ Reader._ERROR_MESSAGES['manifest_error'])
1428
+ manifest_array = (
1429
+ ctypes.c_ubyte *
1430
+ len(manifest_data))(
1431
+ *
1432
+ manifest_data)
1433
+ self._reader = (
1434
+ _lib.c2pa_reader_from_manifest_data_and_stream(
1435
+ format_bytes,
1436
+ self._own_stream._stream,
1437
+ manifest_array,
1438
+ len(manifest_data),
1439
+ )
1447
1440
  )
1448
- )
1449
1441
 
1450
- if not self._reader:
1451
- self._own_stream.close()
1452
- file.close()
1453
- error = _parse_operation_result_for_error(
1454
- _lib.c2pa_error())
1455
- if error:
1456
- raise C2paError(error)
1457
- raise C2paError(
1458
- Reader._ERROR_MESSAGES['reader_error'].format(
1459
- "Unknown error"
1442
+ if not self._reader:
1443
+ self._own_stream.close()
1444
+ error = _parse_operation_result_for_error(
1445
+ _lib.c2pa_error())
1446
+ if error:
1447
+ raise C2paError(error)
1448
+ raise C2paError(
1449
+ Reader._ERROR_MESSAGES['reader_error'].format(
1450
+ "Unknown error"
1451
+ )
1460
1452
  )
1461
- )
1462
1453
 
1463
- self._backing_file = file
1464
-
1465
- self._initialized = True
1454
+ self._backing_file = file
1455
+ self._initialized = True
1466
1456
  except Exception as e:
1457
+ # File closed by context manager
1467
1458
  if self._own_stream:
1468
1459
  self._own_stream.close()
1469
1460
  if hasattr(self, '_backing_file') and self._backing_file:
@@ -2309,6 +2300,12 @@ class Builder:
2309
2300
  C2paError: If there was an error adding the ingredient
2310
2301
  C2paError.Encoding: If the ingredient JSON contains
2311
2302
  invalid UTF-8 characters
2303
+
2304
+ Example:
2305
+ ```
2306
+ with open(ingredient_file_path, 'rb') as a_file:
2307
+ builder.add_ingredient(ingredient_json, "image/jpeg", a_file)
2308
+ ```
2312
2309
  """
2313
2310
  return self.add_ingredient_from_stream(ingredient_json, format, source)
2314
2311
 
@@ -2366,7 +2363,7 @@ class Builder:
2366
2363
  ingredient_json: str,
2367
2364
  format: str,
2368
2365
  filepath: Union[str, Path]):
2369
- """Add an ingredient from a file path to the builder.
2366
+ """Add an ingredient from a file path to the builder (deprecated).
2370
2367
  This is a legacy method.
2371
2368
 
2372
2369
  .. deprecated:: 0.13.0
@@ -2635,7 +2632,7 @@ def create_signer(
2635
2632
  certs: str,
2636
2633
  tsa_url: Optional[str] = None
2637
2634
  ) -> Signer:
2638
- """Create a signer from a callback function.
2635
+ """Create a signer from a callback function (deprecated).
2639
2636
 
2640
2637
  .. deprecated:: 0.11.0
2641
2638
  This function is deprecated and will be removed in a future version.
@@ -2670,7 +2667,7 @@ def create_signer(
2670
2667
 
2671
2668
 
2672
2669
  def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer:
2673
- """Create a signer from signer information.
2670
+ """Create a signer from signer information (deprecated).
2674
2671
 
2675
2672
  .. deprecated:: 0.11.0
2676
2673
  This function is deprecated and will be removed in a future version.
@@ -2713,28 +2710,53 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes:
2713
2710
  C2paError: If there was an error signing the data
2714
2711
  C2paError.Encoding: If the private key contains invalid UTF-8 chars
2715
2712
  """
2716
- data_array = (ctypes.c_ubyte * len(data))(*data)
2717
- try:
2718
- key_str = private_key.encode('utf-8')
2719
- except UnicodeError as e:
2720
- raise C2paError.Encoding(
2721
- f"Invalid UTF-8 characters in private key: {str(e)}")
2713
+ if not data:
2714
+ raise C2paError("Data to sign cannot be empty")
2722
2715
 
2723
- signature_ptr = _lib.c2pa_ed25519_sign(data_array, len(data), key_str)
2716
+ if not private_key or not isinstance(private_key, str):
2717
+ raise C2paError("Private key must be a non-empty string")
2724
2718
 
2725
- if not signature_ptr:
2726
- error = _parse_operation_result_for_error(_lib.c2pa_error())
2727
- if error:
2728
- raise C2paError(error)
2729
- raise C2paError("Failed to sign data with Ed25519")
2719
+ # Create secure memory buffer for data
2720
+ data_array = None
2721
+ key_bytes = None
2730
2722
 
2731
2723
  try:
2732
- # Ed25519 signatures are always 64 bytes
2733
- signature = bytes(signature_ptr[:64])
2734
- finally:
2735
- _lib.c2pa_signature_free(signature_ptr)
2724
+ # Create data array with size validation
2725
+ data_size = len(data)
2726
+ data_array = (ctypes.c_ubyte * data_size)(*data)
2736
2727
 
2737
- return signature
2728
+ # Encode private key to bytes
2729
+ try:
2730
+ key_bytes = private_key.encode('utf-8')
2731
+ except UnicodeError as e:
2732
+ raise C2paError.Encoding(
2733
+ f"Invalid UTF-8 characters in private key: {str(e)}")
2734
+
2735
+ # Perform the signing operation
2736
+ signature_ptr = _lib.c2pa_ed25519_sign(
2737
+ data_array,
2738
+ data_size,
2739
+ key_bytes
2740
+ )
2741
+
2742
+ if not signature_ptr:
2743
+ error = _parse_operation_result_for_error(_lib.c2pa_error())
2744
+ if error:
2745
+ raise C2paError(error)
2746
+ raise C2paError("Failed to sign data with Ed25519")
2747
+
2748
+ try:
2749
+ # Ed25519 signatures are always 64 bytes
2750
+ signature = bytes(signature_ptr[:64])
2751
+ finally:
2752
+ _lib.c2pa_signature_free(signature_ptr)
2753
+
2754
+ return signature
2755
+
2756
+ finally:
2757
+ if key_bytes:
2758
+ ctypes.memset(key_bytes, 0, len(key_bytes))
2759
+ del key_bytes
2738
2760
 
2739
2761
 
2740
2762
  __all__ = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.22.0
3
+ Version: 0.23.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
6
  Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
@@ -56,9 +56,14 @@ import c2pa
56
56
  ## Examples
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/sign.py` shows how to sign and verify an asset with a C2PA manifest.
60
61
  - `examples/training.py` demonstrates how to add a "Do Not Train" assertion to an asset and verify it.
61
62
 
63
+ ## API reference documentation
64
+
65
+ See [the section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation).
66
+
62
67
  ## Contributing
63
68
 
64
69
  Contributions are welcome! For more information, see [Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md).
@@ -40,7 +40,7 @@ ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, "cloud.jpg")
40
40
 
41
41
  class TestC2paSdk(unittest.TestCase):
42
42
  def test_sdk_version(self):
43
- self.assertIn("0.64.0", sdk_version())
43
+ self.assertIn("0.65.1", sdk_version())
44
44
 
45
45
 
46
46
  class TestReader(unittest.TestCase):
@@ -1124,7 +1124,7 @@ class TestBuilderWithSigner(unittest.TestCase):
1124
1124
 
1125
1125
  builder.close()
1126
1126
 
1127
- # Settings are global, so we reset to the default "true" here
1127
+ # Settings are thread-local, so we reset to the default "true" here
1128
1128
  load_settings('{"builder": { "thumbnail": {"enabled": true}}}')
1129
1129
 
1130
1130
  def test_builder_sign_with_duplicate_ingredient(self):
File without changes
File without changes
File without changes
File without changes