c2pa-python 0.16.0__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {c2pa_python-0.16.0/src/c2pa_python.egg-info → c2pa_python-0.18.0}/PKG-INFO +1 -1
  2. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/pyproject.toml +1 -1
  3. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/src/c2pa/c2pa.py +455 -160
  4. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/src/c2pa/lib.py +11 -18
  5. {c2pa_python-0.16.0 → c2pa_python-0.18.0/src/c2pa_python.egg-info}/PKG-INFO +1 -1
  6. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/tests/test_unit_tests.py +26 -3
  7. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/LICENSE-APACHE +0 -0
  8. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/LICENSE-MIT +0 -0
  9. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/MANIFEST.in +0 -0
  10. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/README.md +0 -0
  11. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/requirements.txt +0 -0
  12. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/scripts/download_artifacts.py +0 -0
  13. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/setup.cfg +0 -0
  14. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/setup.py +0 -0
  15. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/src/c2pa/__init__.py +0 -0
  16. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/src/c2pa/build.py +0 -0
  17. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/src/c2pa_python.egg-info/SOURCES.txt +0 -0
  18. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/src/c2pa_python.egg-info/dependency_links.txt +0 -0
  19. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/src/c2pa_python.egg-info/entry_points.txt +0 -0
  20. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/src/c2pa_python.egg-info/requires.txt +0 -0
  21. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/src/c2pa_python.egg-info/top_level.txt +0 -0
  22. {c2pa_python-0.16.0 → c2pa_python-0.18.0}/tests/test_unit_tests_threaded.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.16.0
3
+ Version: 0.18.0
4
4
  Summary: Python bindings for the C2PA Content Authenticity Initiative (CAI) library
5
5
  Author-email: Gavin Peacock <gvnpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
6
6
  Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "c2pa-python"
7
- version = "0.16.0"
7
+ version = "0.18.0"
8
8
  requires-python = ">=3.10"
9
9
  description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library"
10
10
  readme = { file = "README.md", content-type = "text/markdown" }
@@ -14,6 +14,7 @@
14
14
  import ctypes
15
15
  import enum
16
16
  import json
17
+ import logging
17
18
  import sys
18
19
  import os
19
20
  import warnings
@@ -23,6 +24,10 @@ import io
23
24
  from .lib import dynamically_load_library
24
25
  import mimetypes
25
26
 
27
+ # Create a module-specific logger
28
+ logger = logging.getLogger("c2pa")
29
+ logger.addHandler(logging.NullHandler())
30
+
26
31
  # Define required function names
27
32
  _REQUIRED_FUNCTIONS = [
28
33
  'c2pa_version',
@@ -255,6 +260,12 @@ class C2paSignerInfo(ctypes.Structure):
255
260
  private_key: The private key as a string
256
261
  ta_url: The timestamp authority URL as bytes
257
262
  """
263
+
264
+ if sign_cert is None:
265
+ raise ValueError("sign_cert must be set")
266
+ if private_key is None:
267
+ raise ValueError("private_key must be set")
268
+
258
269
  # Handle alg parameter: can be C2paSigningAlg enum
259
270
  # or string (or bytes), convert as needed
260
271
  if isinstance(alg, C2paSigningAlg):
@@ -545,19 +556,31 @@ def _convert_to_py_string(value) -> str:
545
556
  return ""
546
557
 
547
558
  py_string = ""
548
- ptr = ctypes.cast(value, ctypes.c_char_p)
549
559
 
550
- # Only if we got a valid pointer
551
- if ptr and ptr.value is not None:
552
- try:
553
- py_string = ptr.value.decode('utf-8', errors='replace')
554
- except Exception:
555
- py_string = ""
560
+ # Validate pointer before casting and freeing
561
+ if not isinstance(value, (int, ctypes.c_void_p)) or value == 0:
562
+ return ""
556
563
 
557
- # Free the Rust-allocated memory
558
- _lib.c2pa_string_free(value)
564
+ try:
565
+ ptr = ctypes.cast(value, ctypes.c_char_p)
566
+
567
+ # Only if we got a valid pointer with valid content
568
+ if ptr and ptr.value is not None:
569
+ try:
570
+ py_string = ptr.value.decode('utf-8', errors='replace')
571
+ except Exception:
572
+ py_string = ""
573
+ finally:
574
+ # Only free if we have a valid pointer
575
+ try:
576
+ _lib.c2pa_string_free(value)
577
+ except Exception:
578
+ # Ignore clean up issues
579
+ pass
580
+ except (ctypes.ArgumentError, TypeError, ValueError):
581
+ # Invalid pointer type or value
582
+ return ""
559
583
 
560
- # In case of invalid pointer, no free (avoids double-free)
561
584
  return py_string
562
585
 
563
586
 
@@ -617,7 +640,7 @@ def sdk_version() -> str:
617
640
  Returns the underlying c2pa-rs/c2pa-c-ffi version string
618
641
  """
619
642
  vstr = version()
620
- # Example: "c2pa-c/0.49.5 c2pa-rs/0.49.5"
643
+ # Example: "c2pa-c/0.60.1 c2pa-rs/0.60.1"
621
644
  for part in vstr.split():
622
645
  if part.startswith("c2pa-rs/"):
623
646
  return part.split("/", 1)[1]
@@ -654,6 +677,33 @@ def load_settings(settings: str, format: str = "json") -> None:
654
677
  return result
655
678
 
656
679
 
680
+ def _get_mime_type_from_path(path: Union[str, Path]) -> str:
681
+ """Attempt to guess the MIME type from a file path (with extension).
682
+
683
+ Args:
684
+ path: File path as string or Path object
685
+
686
+ Returns:
687
+ MIME type string
688
+
689
+ Raises:
690
+ C2paError.NotSupported: If MIME type cannot be determined
691
+ """
692
+ path_obj = Path(path)
693
+ file_extension = path_obj.suffix.lower() if path_obj.suffix else ""
694
+
695
+ if file_extension == ".dng":
696
+ # mimetypes guesses the wrong type for dng,
697
+ # so we bypass it and set the correct type
698
+ return "image/dng"
699
+ else:
700
+ mime_type = mimetypes.guess_type(str(path))[0]
701
+ if not mime_type:
702
+ raise C2paError.NotSupported(
703
+ f"Could not determine MIME type for file: {path}")
704
+ return mime_type
705
+
706
+
657
707
  def read_ingredient_file(
658
708
  path: Union[str, Path], data_dir: Union[str, Path]) -> str:
659
709
  """Read a file as C2PA ingredient.
@@ -803,7 +853,9 @@ def sign_file(
803
853
  Use :meth:`Builder.sign` instead.
804
854
 
805
855
  Args:
806
- source_path: Path to the source file
856
+ source_path: Path to the source file. We will attempt
857
+ to guess the mimetype of the source file based on
858
+ the extension.
807
859
  dest_path: Path to write the signed file to
808
860
  manifest: The manifest JSON string
809
861
  signer_or_info: Either a signer configuration or a signer object
@@ -860,6 +912,7 @@ def sign_file(
860
912
  try:
861
913
  os.remove(dest_path)
862
914
  except OSError:
915
+ logger.warning("Failed to remove destination file")
863
916
  pass # Ignore cleanup errors
864
917
 
865
918
  # Re-raise the error
@@ -1097,9 +1150,30 @@ class Stream:
1097
1150
  self.close()
1098
1151
 
1099
1152
  def __del__(self):
1100
- """Ensure resources are cleaned up if close() wasn't called."""
1101
- if hasattr(self, '_closed'):
1102
- self.close()
1153
+ """Ensure resources are cleaned up if close()wasn't called.
1154
+
1155
+ This destructor only cleans up if the object
1156
+ hasn't been explicitly closed.
1157
+ """
1158
+ try:
1159
+ # Only cleanup if not already closed and we have a valid stream
1160
+ if hasattr(self, '_closed') and not self._closed:
1161
+ if hasattr(self, '_stream') and self._stream:
1162
+ # Use internal cleanup to avoid calling close() which could
1163
+ # cause issues
1164
+ try:
1165
+ _lib.c2pa_release_stream(self._stream)
1166
+ except Exception:
1167
+ # Destructors shouldn't raise exceptions
1168
+ logger.error("Failed to release Stream")
1169
+ pass
1170
+ finally:
1171
+ self._stream = None
1172
+ self._closed = True
1173
+ self._initialized = False
1174
+ except Exception:
1175
+ # Destructors must not raise exceptions
1176
+ pass
1103
1177
 
1104
1178
  def close(self):
1105
1179
  """Release the stream resources.
@@ -1121,9 +1195,9 @@ class Stream:
1121
1195
  try:
1122
1196
  _lib.c2pa_release_stream(self._stream)
1123
1197
  except Exception as e:
1124
- print(
1198
+ logger.error(
1125
1199
  Stream._ERROR_MESSAGES['stream_error'].format(
1126
- str(e)), file=sys.stderr)
1200
+ str(e)))
1127
1201
  finally:
1128
1202
  self._stream = None
1129
1203
 
@@ -1133,14 +1207,14 @@ class Stream:
1133
1207
  try:
1134
1208
  setattr(self, attr, None)
1135
1209
  except Exception as e:
1136
- print(
1210
+ logger.error(
1137
1211
  Stream._ERROR_MESSAGES['callback_error'].format(
1138
- attr, str(e)), file=sys.stderr)
1212
+ attr, str(e)))
1139
1213
 
1140
1214
  except Exception as e:
1141
- print(
1215
+ logger.error(
1142
1216
  Stream._ERROR_MESSAGES['cleanup_error'].format(
1143
- str(e)), file=sys.stderr)
1217
+ str(e)))
1144
1218
  finally:
1145
1219
  self._closed = True
1146
1220
  self._initialized = False
@@ -1184,26 +1258,71 @@ class Reader:
1184
1258
  'stream_error': "Error cleaning up stream: {}",
1185
1259
  'file_error': "Error cleaning up file: {}",
1186
1260
  'reader_cleanup_error': "Error cleaning up reader: {}",
1187
- 'encoding_error': "Invalid UTF-8 characters in input: {}"
1261
+ 'encoding_error': "Invalid UTF-8 characters in input: {}",
1262
+ 'closed_error': "Reader is closed"
1188
1263
  }
1189
1264
 
1190
1265
  @classmethod
1191
1266
  def get_supported_mime_types(cls) -> list[str]:
1267
+ """Get the list of supported MIME types for the Reader.
1268
+ This method retrieves supported MIME types from the native library.
1269
+
1270
+ Returns:
1271
+ List of supported MIME type strings
1272
+
1273
+ Raises:
1274
+ C2paError: If there was an error retrieving the MIME types
1275
+ """
1192
1276
  if cls._supported_mime_types_cache is not None:
1193
1277
  return cls._supported_mime_types_cache
1194
1278
 
1195
1279
  count = ctypes.c_size_t()
1196
1280
  arr = _lib.c2pa_reader_supported_mime_types(ctypes.byref(count))
1197
1281
 
1282
+ # Validate the returned array pointer
1283
+ if not arr:
1284
+ # If no array returned, check for errors
1285
+ error = _parse_operation_result_for_error(_lib.c2pa_error())
1286
+ if error:
1287
+ raise C2paError(f"Failed to get supported MIME types: {error}")
1288
+ # Return empty list if no error but no array
1289
+ return []
1290
+
1291
+ # Validate count value
1292
+ if count.value <= 0:
1293
+ # Free the array even if count is invalid
1294
+ try:
1295
+ _lib.c2pa_free_string_array(arr, count.value)
1296
+ except Exception:
1297
+ pass
1298
+ return []
1299
+
1198
1300
  try:
1199
- # CDecode values to place them in Python managed memory
1200
- result = [arr[i].decode("utf-8") for i in range(count.value)]
1301
+ result = []
1302
+ for i in range(count.value):
1303
+ try:
1304
+ # Validate each array element before accessing
1305
+ if arr[i] is None:
1306
+ continue
1307
+
1308
+ mime_type = arr[i].decode("utf-8", errors='replace')
1309
+ if mime_type:
1310
+ result.append(mime_type)
1311
+ except Exception:
1312
+ # Ignore cleanup errors
1313
+ continue
1201
1314
  finally:
1202
- # Release native memory, as per API contract
1203
- # c2pa_reader_supported_mime_types must call c2pa_free_string_array
1204
- _lib.c2pa_free_string_array(arr, count.value)
1315
+ # Always free the native memory, even if string extraction fails
1316
+ try:
1317
+ _lib.c2pa_free_string_array(arr, count.value)
1318
+ except Exception:
1319
+ # Ignore cleanup errors
1320
+ pass
1321
+
1322
+ # Cache the result
1323
+ if result:
1324
+ cls._supported_mime_types_cache = result
1205
1325
 
1206
- cls._supported_mime_types_cache = result
1207
1326
  return cls._supported_mime_types_cache
1208
1327
 
1209
1328
  def __init__(self,
@@ -1224,15 +1343,20 @@ class Reader:
1224
1343
  contain invalid UTF-8 characters
1225
1344
  """
1226
1345
 
1346
+ self._closed = False
1347
+
1227
1348
  self._reader = None
1228
1349
  self._own_stream = None
1229
1350
 
1351
+ # This is used to keep track of a file
1352
+ # we may have opened ourselves, and that we need to close later
1353
+ self._backing_file = None
1354
+
1230
1355
  if stream is None:
1231
1356
  # If we don't get a stream as param:
1232
1357
  # Create a stream from the file path in format_or_path
1233
1358
  path = str(format_or_path)
1234
- mime_type = mimetypes.guess_type(
1235
- path)[0]
1359
+ mime_type = _get_mime_type_from_path(path)
1236
1360
 
1237
1361
  if not mime_type:
1238
1362
  raise C2paError.NotSupported(
@@ -1243,7 +1367,7 @@ class Reader:
1243
1367
  f"Reader does not support {mime_type}")
1244
1368
 
1245
1369
  try:
1246
- self._mime_type_str = mime_type.encode('utf-8')
1370
+ mime_type_str = mime_type.encode('utf-8')
1247
1371
  except UnicodeError as e:
1248
1372
  raise C2paError.Encoding(
1249
1373
  Reader._ERROR_MESSAGES['encoding_error'].format(
@@ -1255,7 +1379,7 @@ class Reader:
1255
1379
  self._own_stream = Stream(file)
1256
1380
 
1257
1381
  self._reader = _lib.c2pa_reader_from_stream(
1258
- self._mime_type_str,
1382
+ mime_type_str,
1259
1383
  self._own_stream._stream
1260
1384
  )
1261
1385
 
@@ -1273,13 +1397,13 @@ class Reader:
1273
1397
  )
1274
1398
 
1275
1399
  # Store the file to close it later
1276
- self._file_like_stream = file
1400
+ self._backing_file = file
1277
1401
 
1278
1402
  except Exception as e:
1279
1403
  if self._own_stream:
1280
1404
  self._own_stream.close()
1281
- if hasattr(self, '_file_like_stream'):
1282
- self._file_like_stream.close()
1405
+ if hasattr(self, '_backing_file') and self._backing_file:
1406
+ self._backing_file.close()
1283
1407
  raise C2paError.Io(
1284
1408
  Reader._ERROR_MESSAGES['io_error'].format(
1285
1409
  str(e)))
@@ -1288,7 +1412,7 @@ class Reader:
1288
1412
  # If stream is a string, treat it as a path and try to open it
1289
1413
 
1290
1414
  # format_or_path is a format
1291
- if format_or_path not in Reader.get_supported_mime_types():
1415
+ if format_or_path.lower() not in Reader.get_supported_mime_types():
1292
1416
  raise C2paError.NotSupported(
1293
1417
  f"Reader does not support {format_or_path}")
1294
1418
 
@@ -1297,11 +1421,11 @@ class Reader:
1297
1421
  self._own_stream = Stream(file)
1298
1422
 
1299
1423
  format_str = str(format_or_path)
1300
- self._format_str = format_str.encode('utf-8')
1424
+ format_bytes = format_str.encode('utf-8')
1301
1425
 
1302
1426
  if manifest_data is None:
1303
1427
  self._reader = _lib.c2pa_reader_from_stream(
1304
- self._format_str, self._own_stream._stream)
1428
+ format_bytes, self._own_stream._stream)
1305
1429
  else:
1306
1430
  if not isinstance(manifest_data, bytes):
1307
1431
  raise TypeError(
@@ -1313,7 +1437,7 @@ class Reader:
1313
1437
  manifest_data)
1314
1438
  self._reader = (
1315
1439
  _lib.c2pa_reader_from_manifest_data_and_stream(
1316
- self._format_str,
1440
+ format_bytes,
1317
1441
  self._own_stream._stream,
1318
1442
  manifest_array,
1319
1443
  len(manifest_data),
@@ -1333,19 +1457,19 @@ class Reader:
1333
1457
  )
1334
1458
  )
1335
1459
 
1336
- self._file_like_stream = file
1460
+ self._backing_file = file
1337
1461
  except Exception as e:
1338
1462
  if self._own_stream:
1339
1463
  self._own_stream.close()
1340
- if hasattr(self, '_file_like_stream'):
1341
- self._file_like_stream.close()
1464
+ if hasattr(self, '_backing_file') and self._backing_file:
1465
+ self._backing_file.close()
1342
1466
  raise C2paError.Io(
1343
1467
  Reader._ERROR_MESSAGES['io_error'].format(
1344
1468
  str(e)))
1345
1469
  else:
1346
1470
  # format_or_path is a format string
1347
1471
  format_str = str(format_or_path)
1348
- if format_str not in Reader.get_supported_mime_types():
1472
+ if format_str.lower() not in Reader.get_supported_mime_types():
1349
1473
  raise C2paError.NotSupported(
1350
1474
  f"Reader does not support {format_str}")
1351
1475
 
@@ -1386,11 +1510,85 @@ class Reader:
1386
1510
  )
1387
1511
 
1388
1512
  def __enter__(self):
1513
+ self._ensure_valid_state()
1514
+
1515
+ if not self._reader:
1516
+ raise C2paError("Invalid Reader when entering context")
1517
+
1389
1518
  return self
1390
1519
 
1391
1520
  def __exit__(self, exc_type, exc_val, exc_tb):
1392
1521
  self.close()
1393
1522
 
1523
+ def __del__(self):
1524
+ """Ensure resources are cleaned up if close() wasn't called.
1525
+
1526
+ This destructor handles cleanup without causing double frees.
1527
+ It only cleans up if the object hasn't been explicitly closed.
1528
+ """
1529
+ self._cleanup_resources()
1530
+
1531
+ def _ensure_valid_state(self):
1532
+ """Ensure the reader is in a valid state for operations.
1533
+
1534
+ Raises:
1535
+ C2paError: If the reader is closed or invalid
1536
+ """
1537
+ if self._closed:
1538
+ raise C2paError(Reader._ERROR_MESSAGES['closed_error'])
1539
+ if not self._reader:
1540
+ raise C2paError(Reader._ERROR_MESSAGES['closed_error'])
1541
+
1542
+ def _cleanup_resources(self):
1543
+ """Internal cleanup method that releases native resources.
1544
+
1545
+ This method handles the actual cleanup logic and can be called
1546
+ from both close() and __del__ without causing double frees.
1547
+ """
1548
+ try:
1549
+ # Only cleanup if not already closed and we have a valid reader
1550
+ if hasattr(self, '_closed') and not self._closed:
1551
+ self._closed = True
1552
+
1553
+ # Clean up reader
1554
+ if hasattr(self, '_reader') and self._reader:
1555
+ try:
1556
+ _lib.c2pa_reader_free(self._reader)
1557
+ except Exception:
1558
+ # Cleanup failure doesn't raise exceptions
1559
+ logger.error(
1560
+ "Failed to free native Reader resources"
1561
+ )
1562
+ pass
1563
+ finally:
1564
+ self._reader = None
1565
+
1566
+ # Clean up stream
1567
+ if hasattr(self, '_own_stream') and self._own_stream:
1568
+ try:
1569
+ self._own_stream.close()
1570
+ except Exception:
1571
+ # Cleanup failure doesn't raise exceptions
1572
+ logger.error("Failed to close Reader stream")
1573
+ pass
1574
+ finally:
1575
+ self._own_stream = None
1576
+
1577
+ # Clean up backing file (if needed)
1578
+ if self._backing_file:
1579
+ try:
1580
+ self._backing_file.close()
1581
+ except Exception:
1582
+ # Cleanup failure doesn't raise exceptions
1583
+ logger.warning("Failed to close Reader backing file")
1584
+ pass
1585
+ finally:
1586
+ self._backing_file = None
1587
+
1588
+ except Exception:
1589
+ # Ensure we don't raise exceptions during cleanup
1590
+ pass
1591
+
1394
1592
  def close(self):
1395
1593
  """Release the reader resources.
1396
1594
 
@@ -1399,55 +1597,17 @@ class Reader:
1399
1597
  Errors during cleanup are logged but not raised to ensure cleanup.
1400
1598
  Multiple calls to close() are handled gracefully.
1401
1599
  """
1402
-
1403
- # Track if we've already cleaned up
1404
- if not hasattr(self, '_closed'):
1405
- self._closed = False
1406
-
1407
1600
  if self._closed:
1408
1601
  return
1409
1602
 
1410
1603
  try:
1411
- # Clean up reader
1412
- if hasattr(self, '_reader') and self._reader:
1413
- try:
1414
- _lib.c2pa_reader_free(self._reader)
1415
- except Exception as e:
1416
- print(
1417
- Reader._ERROR_MESSAGES['reader_cleanup_error'].format(
1418
- str(e)), file=sys.stderr)
1419
- finally:
1420
- self._reader = None
1421
-
1422
- # Clean up stream
1423
- if hasattr(self, '_own_stream') and self._own_stream:
1424
- try:
1425
- self._own_stream.close()
1426
- except Exception as e:
1427
- print(
1428
- Reader._ERROR_MESSAGES['stream_error'].format(
1429
- str(e)), file=sys.stderr)
1430
- finally:
1431
- self._own_stream = None
1432
-
1433
- # Clean up file
1434
- if hasattr(self, '_file_like_stream'):
1435
- try:
1436
- self._file_like_stream.close()
1437
- except Exception as e:
1438
- print(
1439
- Reader._ERROR_MESSAGES['file_error'].format(
1440
- str(e)), file=sys.stderr)
1441
- finally:
1442
- self._file_like_stream = None
1443
-
1444
- # Clear any stored strings
1445
- if hasattr(self, '_strings'):
1446
- self._strings.clear()
1604
+ # Use the internal cleanup method
1605
+ self._cleanup_resources()
1447
1606
  except Exception as e:
1448
- print(
1607
+ # Log any unexpected errors during close
1608
+ logger.error(
1449
1609
  Reader._ERROR_MESSAGES['cleanup_error'].format(
1450
- str(e)), file=sys.stderr)
1610
+ str(e)))
1451
1611
  finally:
1452
1612
  self._closed = True
1453
1613
 
@@ -1461,8 +1621,8 @@ class Reader:
1461
1621
  C2paError: If there was an error getting the JSON
1462
1622
  """
1463
1623
 
1464
- if not self._reader:
1465
- raise C2paError("Reader is closed")
1624
+ self._ensure_valid_state()
1625
+
1466
1626
  result = _lib.c2pa_reader_json(self._reader)
1467
1627
 
1468
1628
  if result is None:
@@ -1486,13 +1646,12 @@ class Reader:
1486
1646
  Raises:
1487
1647
  C2paError: If there was an error writing the resource to stream
1488
1648
  """
1489
- if not self._reader:
1490
- raise C2paError("Reader is closed")
1649
+ self._ensure_valid_state()
1491
1650
 
1492
- self._uri_str = uri.encode('utf-8')
1651
+ uri_str = uri.encode('utf-8')
1493
1652
  with Stream(stream) as stream_obj:
1494
1653
  result = _lib.c2pa_reader_resource_to_stream(
1495
- self._reader, self._uri_str, stream_obj._stream)
1654
+ self._reader, uri_str, stream_obj._stream)
1496
1655
 
1497
1656
  if result < 0:
1498
1657
  error = _parse_operation_result_for_error(_lib.c2pa_error())
@@ -1526,7 +1685,17 @@ class Signer:
1526
1685
 
1527
1686
  Note: This constructor is not meant to be called directly.
1528
1687
  Use from_info() or from_callback() instead.
1688
+
1689
+ Args:
1690
+ signer_ptr: Pointer to the native C2PA signer
1691
+
1692
+ Raises:
1693
+ C2paError: If the signer pointer is invalid
1529
1694
  """
1695
+ # Validate pointer before assignment
1696
+ if not signer_ptr:
1697
+ raise C2paError("Invalid signer pointer: pointer is null")
1698
+
1530
1699
  self._signer = signer_ptr
1531
1700
  self._closed = False
1532
1701
 
@@ -1652,9 +1821,9 @@ class Signer:
1652
1821
  # Native code expects the signed len to be returned, we oblige
1653
1822
  return actual_len
1654
1823
  except Exception as e:
1655
- print(
1824
+ logger.error(
1656
1825
  cls._ERROR_MESSAGES['callback_error'].format(
1657
- str(e)), file=sys.stderr)
1826
+ str(e)))
1658
1827
  # Error: exception raised, invalid so return -1,
1659
1828
  # native code will handle the error when seeing -1
1660
1829
  return -1
@@ -1701,40 +1870,85 @@ class Signer:
1701
1870
 
1702
1871
  def __enter__(self):
1703
1872
  """Context manager entry."""
1704
- if self._closed:
1705
- raise C2paError(Signer._ERROR_MESSAGES['closed_error'])
1873
+ self._ensure_valid_state()
1874
+
1875
+ if not self._signer:
1876
+ raise C2paError("Invalid signer pointer: pointer is null")
1877
+
1706
1878
  return self
1707
1879
 
1708
1880
  def __exit__(self, exc_type, exc_val, exc_tb):
1709
1881
  """Context manager exit."""
1710
1882
  self.close()
1711
1883
 
1884
+ def _cleanup_resources(self):
1885
+ """Internal cleanup method that releases native resources.
1886
+
1887
+ This method handles the actual cleanup logic and can be called
1888
+ from both close() and __del__ without causing double frees.
1889
+ """
1890
+ try:
1891
+ if not self._closed and self._signer:
1892
+ self._closed = True
1893
+
1894
+ try:
1895
+ _lib.c2pa_signer_free(self._signer)
1896
+ except Exception:
1897
+ # Log cleanup errors but don't raise exceptions
1898
+ logger.error("Failed to free native Signer resources")
1899
+ finally:
1900
+ self._signer = None
1901
+
1902
+ # Clean up callback reference
1903
+ if self._callback_cb:
1904
+ self._callback_cb = None
1905
+
1906
+ except Exception:
1907
+ # Ensure we don't raise exceptions during cleanup
1908
+ pass
1909
+
1910
+ def _ensure_valid_state(self):
1911
+ """Ensure the signer is in a valid state for operations.
1912
+
1913
+ Raises:
1914
+ C2paError: If the signer is closed or invalid
1915
+ """
1916
+ if self._closed:
1917
+ raise C2paError(Signer._ERROR_MESSAGES['closed_error'])
1918
+ if not self._signer:
1919
+ raise C2paError(Signer._ERROR_MESSAGES['closed_error'])
1920
+
1712
1921
  def close(self):
1713
1922
  """Release the signer resources.
1714
1923
 
1715
1924
  This method ensures all resources are properly cleaned up,
1716
1925
  even if errors occur during cleanup.
1717
- Errors during cleanup are logged but not raised to ensure cleanup.
1718
- Multiple calls to close() are handled gracefully.
1926
+
1927
+ Note:
1928
+ Multiple calls to close() are handled gracefully.
1929
+ Errors during cleanup are logged but not raised
1930
+ to ensure cleanup.
1719
1931
  """
1720
1932
  if self._closed:
1721
1933
  return
1722
1934
 
1723
1935
  try:
1724
- if self._signer:
1725
- try:
1726
- _lib.c2pa_signer_free(self._signer)
1727
- except Exception as e:
1728
- print(
1729
- Signer._ERROR_MESSAGES['signer_cleanup'].format(
1730
- str(e)), file=sys.stderr)
1731
- finally:
1732
- self._signer = None
1936
+ # Validate pointer before cleanup if it exists
1937
+ if self._signer and self._signer != 0:
1938
+ # Use the internal cleanup method
1939
+ self._cleanup_resources()
1940
+ else:
1941
+ # Make sure to release the callback
1942
+ if self._callback_cb:
1943
+ self._callback_cb = None
1944
+
1733
1945
  except Exception as e:
1734
- print(
1946
+ # Log any unexpected errors during close
1947
+ logger.error(
1735
1948
  Signer._ERROR_MESSAGES['cleanup_error'].format(
1736
- str(e)), file=sys.stderr)
1949
+ str(e)))
1737
1950
  finally:
1951
+ # Always mark as closed, regardless of cleanup success
1738
1952
  self._closed = True
1739
1953
 
1740
1954
  def reserve_size(self) -> int:
@@ -1746,8 +1960,7 @@ class Signer:
1746
1960
  Raises:
1747
1961
  C2paError: If there was an error getting the size
1748
1962
  """
1749
- if self._closed or not self._signer:
1750
- raise C2paError(Signer._ERROR_MESSAGES['closed_error'])
1963
+ self._ensure_valid_state()
1751
1964
 
1752
1965
  result = _lib.c2pa_signer_reserve_size(self._signer)
1753
1966
 
@@ -1793,22 +2006,65 @@ class Builder:
1793
2006
 
1794
2007
  @classmethod
1795
2008
  def get_supported_mime_types(cls) -> list[str]:
2009
+ """Get the list of supported MIME types for the Builder.
2010
+ This method retrieves supported MIME types from the native library.
2011
+
2012
+ Returns:
2013
+ List of supported MIME type strings
2014
+
2015
+ Raises:
2016
+ C2paError: If there was an error retrieving the MIME types
2017
+ """
1796
2018
  if cls._supported_mime_types_cache is not None:
1797
2019
  return cls._supported_mime_types_cache
1798
2020
 
1799
2021
  count = ctypes.c_size_t()
1800
2022
  arr = _lib.c2pa_builder_supported_mime_types(ctypes.byref(count))
1801
2023
 
2024
+ # Validate the returned array pointer
2025
+ if not arr:
2026
+ # If no array returned, check for errors
2027
+ error = _parse_operation_result_for_error(_lib.c2pa_error())
2028
+ if error:
2029
+ raise C2paError(f"Failed to get supported MIME types: {error}")
2030
+ # Return empty list if no error but no array
2031
+ return []
2032
+
2033
+ # Validate count value
2034
+ if count.value <= 0:
2035
+ # Free the array even if count is invalid
2036
+ try:
2037
+ _lib.c2pa_free_string_array(arr, count.value)
2038
+ except Exception:
2039
+ pass
2040
+ return []
2041
+
1802
2042
  try:
1803
- # CDecode values to place them in Python managed memory
1804
- result = [arr[i].decode("utf-8") for i in range(count.value)]
2043
+ result = []
2044
+ for i in range(count.value):
2045
+ try:
2046
+ # Validate each array element before accessing
2047
+ if arr[i] is None:
2048
+ continue
2049
+
2050
+ mime_type = arr[i].decode("utf-8", errors='replace')
2051
+ if mime_type:
2052
+ result.append(mime_type)
2053
+ except Exception:
2054
+ # Ignore decoding failures
2055
+ continue
1805
2056
  finally:
1806
- # Release native memory, as per API contract
1807
- # c2pa_builder_supported_mime_types must call
1808
- # c2pa_free_string_array
1809
- _lib.c2pa_free_string_array(arr, count.value)
2057
+ # Always free the native memory, even if string extraction fails
2058
+ try:
2059
+ _lib.c2pa_free_string_array(arr, count.value)
2060
+ except Exception:
2061
+ # Ignore cleanup errors
2062
+ pass
2063
+
2064
+ # Cache the result
2065
+ if result:
2066
+ cls._supported_mime_types_cache = result
1810
2067
 
1811
- cls._supported_mime_types_cache = result
1812
2068
  return cls._supported_mime_types_cache
1813
2069
 
1814
2070
  def __init__(self, manifest_json: Any):
@@ -1822,6 +2078,7 @@ class Builder:
1822
2078
  C2paError.Encoding: If manifest JSON contains invalid UTF-8 chars
1823
2079
  C2paError.Json: If the manifest JSON cannot be serialized
1824
2080
  """
2081
+ self._closed = False
1825
2082
  self._builder = None
1826
2083
 
1827
2084
  if not isinstance(manifest_json, str):
@@ -1885,6 +2142,9 @@ class Builder:
1885
2142
  builder._builder = _lib.c2pa_builder_from_archive(stream_obj._stream)
1886
2143
 
1887
2144
  if not builder._builder:
2145
+ # Clean up the stream object if builder creation fails
2146
+ stream_obj.close()
2147
+
1888
2148
  error = _parse_operation_result_for_error(_lib.c2pa_error())
1889
2149
  if error:
1890
2150
  raise C2paError(error)
@@ -1892,10 +2152,48 @@ class Builder:
1892
2152
 
1893
2153
  return builder
1894
2154
 
2155
+ def _ensure_valid_state(self):
2156
+ """Ensure the builder is in a valid state for operations.
2157
+
2158
+ Raises:
2159
+ C2paError: If the builder is closed or invalid
2160
+ """
2161
+ if self._closed:
2162
+ raise C2paError(Builder._ERROR_MESSAGES['closed_error'])
2163
+ if not self._builder:
2164
+ raise C2paError(Builder._ERROR_MESSAGES['closed_error'])
2165
+
2166
+ def _cleanup_resources(self):
2167
+ """Internal cleanup method that releases native resources.
2168
+
2169
+ This method handles the actual cleanup logic and can be called
2170
+ from both close() and __del__ without causing double frees.
2171
+ """
2172
+ try:
2173
+ # Only cleanup if not already closed and we have a valid builder
2174
+ if hasattr(self, '_closed') and not self._closed:
2175
+ self._closed = True
2176
+
2177
+ if hasattr(
2178
+ self,
2179
+ '_builder') and self._builder and self._builder != 0:
2180
+ try:
2181
+ _lib.c2pa_builder_free(self._builder)
2182
+ except Exception:
2183
+ # Log cleanup errors but don't raise exceptions
2184
+ logger.error(
2185
+ "Failed to release native Builder resources"
2186
+ )
2187
+ pass
2188
+ finally:
2189
+ self._builder = None
2190
+ except Exception:
2191
+ # Ensure we don't raise exceptions during cleanup
2192
+ pass
2193
+
1895
2194
  def __del__(self):
1896
2195
  """Ensure resources are cleaned up if close() wasn't called."""
1897
- if hasattr(self, '_closed'):
1898
- self.close()
2196
+ self._cleanup_resources()
1899
2197
 
1900
2198
  def close(self):
1901
2199
  """Release the builder resources.
@@ -1905,32 +2203,26 @@ class Builder:
1905
2203
  Errors during cleanup are logged but not raised to ensure cleanup.
1906
2204
  Multiple calls to close() are handled gracefully.
1907
2205
  """
1908
- # Track if we've already cleaned up
1909
- if not hasattr(self, '_closed'):
1910
- self._closed = False
1911
-
1912
2206
  if self._closed:
1913
2207
  return
1914
2208
 
1915
2209
  try:
1916
- # Clean up builder
1917
- if hasattr(self, '_builder') and self._builder:
1918
- try:
1919
- _lib.c2pa_builder_free(self._builder)
1920
- except Exception as e:
1921
- print(
1922
- Builder._ERROR_MESSAGES['builder_cleanup'].format(
1923
- str(e)), file=sys.stderr)
1924
- finally:
1925
- self._builder = None
2210
+ # Use the internal cleanup method
2211
+ self._cleanup_resources()
1926
2212
  except Exception as e:
1927
- print(
2213
+ # Log any unexpected errors during close
2214
+ logger.error(
1928
2215
  Builder._ERROR_MESSAGES['cleanup_error'].format(
1929
- str(e)), file=sys.stderr)
2216
+ str(e)))
1930
2217
  finally:
1931
2218
  self._closed = True
1932
2219
 
1933
2220
  def __enter__(self):
2221
+ self._ensure_valid_state()
2222
+
2223
+ if not self._builder:
2224
+ raise C2paError("Invalid Builder when entering context")
2225
+
1934
2226
  return self
1935
2227
 
1936
2228
  def __exit__(self, exc_type, exc_val, exc_tb):
@@ -1943,9 +2235,7 @@ class Builder:
1943
2235
  into the asset when signing.
1944
2236
  This is useful when creating cloud or sidecar manifests.
1945
2237
  """
1946
- if not self._builder:
1947
- raise C2paError(Builder._ERROR_MESSAGES['closed_error'])
1948
-
2238
+ self._ensure_valid_state()
1949
2239
  _lib.c2pa_builder_set_no_embed(self._builder)
1950
2240
 
1951
2241
  def set_remote_url(self, remote_url: str):
@@ -1960,8 +2250,7 @@ class Builder:
1960
2250
  Raises:
1961
2251
  C2paError: If there was an error setting the remote URL
1962
2252
  """
1963
- if not self._builder:
1964
- raise C2paError(Builder._ERROR_MESSAGES['closed_error'])
2253
+ self._ensure_valid_state()
1965
2254
 
1966
2255
  url_str = remote_url.encode('utf-8')
1967
2256
  result = _lib.c2pa_builder_set_remote_url(self._builder, url_str)
@@ -1984,8 +2273,7 @@ class Builder:
1984
2273
  Raises:
1985
2274
  C2paError: If there was an error adding the resource
1986
2275
  """
1987
- if not self._builder:
1988
- raise C2paError(Builder._ERROR_MESSAGES['closed_error'])
2276
+ self._ensure_valid_state()
1989
2277
 
1990
2278
  uri_str = uri.encode('utf-8')
1991
2279
  with Stream(stream) as stream_obj:
@@ -2167,7 +2455,11 @@ class Builder:
2167
2455
  if not self._builder:
2168
2456
  raise C2paError(Builder._ERROR_MESSAGES['closed_error'])
2169
2457
 
2170
- if format not in Builder.get_supported_mime_types():
2458
+ # Validate signer pointer before use
2459
+ if not signer or not hasattr(signer, '_signer') or not signer._signer:
2460
+ raise C2paError("Invalid or closed signer")
2461
+
2462
+ if format.lower() not in Builder.get_supported_mime_types():
2171
2463
  raise C2paError.NotSupported(
2172
2464
  f"Builder does not support {format}")
2173
2465
 
@@ -2175,14 +2467,18 @@ class Builder:
2175
2467
  manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)()
2176
2468
 
2177
2469
  # c2pa_builder_sign uses streams
2178
- result = _lib.c2pa_builder_sign(
2179
- self._builder,
2180
- format_str,
2181
- source_stream._stream,
2182
- dest_stream._stream,
2183
- signer._signer,
2184
- ctypes.byref(manifest_bytes_ptr)
2185
- )
2470
+ try:
2471
+ result = _lib.c2pa_builder_sign(
2472
+ self._builder,
2473
+ format_str,
2474
+ source_stream._stream,
2475
+ dest_stream._stream,
2476
+ signer._signer,
2477
+ ctypes.byref(manifest_bytes_ptr)
2478
+ )
2479
+ except Exception as e:
2480
+ # Handle errors during the C function call
2481
+ raise C2paError(f"Error calling c2pa_builder_sign: {str(e)}")
2186
2482
 
2187
2483
  if result < 0:
2188
2484
  error = _parse_operation_result_for_error(_lib.c2pa_error())
@@ -2199,8 +2495,6 @@ class Builder:
2199
2495
  ctypes.memmove(temp_buffer, manifest_bytes_ptr, result)
2200
2496
  manifest_bytes = bytes(temp_buffer)
2201
2497
  except Exception:
2202
- # If there's any error accessing the memory, just return
2203
- # empty bytes
2204
2498
  manifest_bytes = b""
2205
2499
  finally:
2206
2500
  # Always free the C-allocated memory,
@@ -2208,7 +2502,9 @@ class Builder:
2208
2502
  try:
2209
2503
  _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr)
2210
2504
  except Exception:
2211
- # Ignore errors during cleanup
2505
+ logger.error(
2506
+ "Failed to release native manifest bytes memory"
2507
+ )
2212
2508
  pass
2213
2509
 
2214
2510
  return manifest_bytes
@@ -2269,7 +2565,9 @@ class Builder:
2269
2565
  """Sign a file and write the signed data to an output file.
2270
2566
 
2271
2567
  Args:
2272
- source_path: Path to the source file
2568
+ source_path: Path to the source file. We will attempt
2569
+ to guess the mimetype of the source file based on
2570
+ the extension.
2273
2571
  dest_path: Path to write the signed file to
2274
2572
  signer: The signer to use
2275
2573
 
@@ -2280,10 +2578,7 @@ class Builder:
2280
2578
  C2paError: If there was an error during signing
2281
2579
  """
2282
2580
  # Get the MIME type from the file extension
2283
- mime_type = mimetypes.guess_type(str(source_path))[0]
2284
- if not mime_type:
2285
- raise C2paError.NotSupported(
2286
- f"Could not determine MIME type for file: {source_path}")
2581
+ mime_type = _get_mime_type_from_path(source_path)
2287
2582
 
2288
2583
  try:
2289
2584
  # Open source file and destination file, then use the sign method
@@ -16,8 +16,9 @@ from enum import Enum
16
16
  # Debug flag for library loading
17
17
  DEBUG_LIBRARY_LOADING = False
18
18
 
19
- # Create a module-specific logger with NullHandler to avoid interfering with global configuration
20
- logger = logging.getLogger("c2pa")
19
+ # Create a module-specific logger with NullHandler
20
+ # to avoid interfering with global configuration
21
+ logger = logging.getLogger("c2pa.loader")
21
22
  logger.addHandler(logging.NullHandler())
22
23
 
23
24
 
@@ -107,8 +108,7 @@ def _load_single_library(lib_name: str,
107
108
  The loaded library or None if loading failed
108
109
  """
109
110
  if DEBUG_LIBRARY_LOADING: # pragma: no cover
110
- logger.info(
111
- f"Searching for library '{lib_name}' in paths: {[str(p) for p in search_paths]}")
111
+ logger.info(f"Searching for library '{lib_name}' in paths: {[str(p) for p in search_paths]}")
112
112
  current_arch = _get_architecture()
113
113
  if DEBUG_LIBRARY_LOADING: # pragma: no cover
114
114
  logger.info(f"Current architecture: {current_arch}")
@@ -125,12 +125,10 @@ def _load_single_library(lib_name: str,
125
125
  except Exception as e:
126
126
  error_msg = str(e)
127
127
  if "incompatible architecture" in error_msg:
128
- logger.error(
129
- f"Architecture mismatch: Library at {lib_path} is not compatible with current architecture {current_arch}")
128
+ logger.error(f"Architecture mismatch: Library at {lib_path} is not compatible with current architecture {current_arch}")
130
129
  logger.error(f"Error details: {error_msg}")
131
130
  else:
132
- logger.error(
133
- f"Failed to load library from {lib_path}: {e}")
131
+ logger.error(f"Failed to load library from {lib_path}: {e}")
134
132
  else:
135
133
  logger.debug(f"Library not found at: {lib_path}")
136
134
  return None
@@ -216,16 +214,14 @@ def dynamically_load_library(
216
214
  env_lib_name = os.environ.get("C2PA_LIBRARY_NAME")
217
215
  if env_lib_name:
218
216
  if DEBUG_LIBRARY_LOADING: # pragma: no cover
219
- logger.info(
220
- f"Using library name from env var C2PA_LIBRARY_NAME: {env_lib_name}")
217
+ logger.info(f"Using library name from env var C2PA_LIBRARY_NAME: {env_lib_name}")
221
218
  try:
222
219
  possible_paths = _get_possible_search_paths()
223
220
  lib = _load_single_library(env_lib_name, possible_paths)
224
221
  if lib:
225
222
  return lib
226
223
  else:
227
- logger.error(
228
- f"Could not find library {env_lib_name} in any of the search paths")
224
+ logger.error(f"Could not find library {env_lib_name} in any of the search paths")
229
225
  # Continue with normal loading if environment variable library
230
226
  # name fails
231
227
  except Exception as e:
@@ -241,12 +237,9 @@ def dynamically_load_library(
241
237
  if not lib:
242
238
  platform_id = get_platform_identifier()
243
239
  current_arch = _get_architecture()
244
- logger.error(
245
- f"Could not find {lib_name} in any of the search paths: {[str(p) for p in possible_paths]}")
246
- logger.error(
247
- f"Platform: {platform_id}, Architecture: {current_arch}")
248
- raise RuntimeError(
249
- f"Could not find {lib_name} in any of the search paths (Platform: {platform_id}, Architecture: {current_arch})")
240
+ logger.error(f"Could not find {lib_name} in any of the search paths: {[str(p) for p in possible_paths]}")
241
+ logger.error(f"Platform: {platform_id}, Architecture: {current_arch}")
242
+ raise RuntimeError(f"Could not find {lib_name} in any of the search paths (Platform: {platform_id}, Architecture: {current_arch})")
250
243
  return lib
251
244
 
252
245
  # Default path (no library name provided in the environment)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: c2pa-python
3
- Version: 0.16.0
3
+ Version: 0.18.0
4
4
  Summary: Python bindings for the C2PA Content Authenticity Initiative (CAI) library
5
5
  Author-email: Gavin Peacock <gvnpeacock@adobe.com>, Tania Mathern <mathern@adobe.com>
6
6
  Maintainer-email: Gavin Peacock <gpeacock@adobe.com>
@@ -126,9 +126,6 @@ class TestReader(unittest.TestCase):
126
126
  def test_reader_close_cleanup(self):
127
127
  with open(self.testPath, "rb") as file:
128
128
  reader = Reader("image/jpeg", file)
129
- # Store references to internal objects
130
- reader_ref = reader._reader
131
- stream_ref = reader._own_stream
132
129
  # Close the reader
133
130
  reader.close()
134
131
  # Verify all resources are cleaned up
@@ -144,6 +141,32 @@ class TestReader(unittest.TestCase):
144
141
  with self.assertRaises(Error):
145
142
  reader.resource_to_stream("", io.BytesIO(bytearray()))
146
143
 
144
+ def test_read_dng_from_stream(self):
145
+ test_path = os.path.join(self.data_dir, "C.dng")
146
+ with open(test_path, "rb") as file:
147
+ file_content = file.read()
148
+
149
+ with Reader("dng", io.BytesIO(file_content)) as reader:
150
+ # Just run and verify there is no crash
151
+ json.loads(reader.json())
152
+
153
+ def test_read_dng_upper_case_from_stream(self):
154
+ test_path = os.path.join(self.data_dir, "C.dng")
155
+ with open(test_path, "rb") as file:
156
+ file_content = file.read()
157
+
158
+ with Reader("DNG", io.BytesIO(file_content)) as reader:
159
+ # Just run and verify there is no crash
160
+ json.loads(reader.json())
161
+
162
+ def test_read_dng_file_from_path(self):
163
+ test_path = os.path.join(self.data_dir, "C.dng")
164
+
165
+ # Create reader with the file content
166
+ with Reader(test_path) as reader:
167
+ # Just run and verify there is no crash
168
+ json.loads(reader.json())
169
+
147
170
  def test_read_all_files(self):
148
171
  """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory"""
149
172
  reading_dir = os.path.join(self.data_dir, "files-for-reading-tests")
File without changes
File without changes
File without changes
File without changes
File without changes