diff --git a/Makefile b/Makefile index 4070ff0e..06afe998 100644 --- a/Makefile +++ b/Makefile @@ -139,7 +139,7 @@ build-from-source: # Build API documentation with Sphinx docs: - python3 scripts/generate_api_docs.py + $(PYTHON) scripts/generate_api_docs.py # Memory profiling with memray (runs in Docker, reports go to tests/perf/reports/) # More details for usage are in tests/perf/README.md diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index c8823f95..f04e61df 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.86.1 +c2pa-v0.88.0 diff --git a/docs/release-notes.md b/docs/release-notes.md index 24c4048a..31ff90a8 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,26 @@ # Release notes +## Version 0.33.0 + +### Breaking changes + +Removed the deprecated file-based free functions `read_file`, +`read_ingredient_file`, and `sign_file`, following the removal of the +underlying `c2pa_read_file`, `c2pa_read_ingredient_file`, and `c2pa_sign_file` +functions from the c2pa-rs C FFI. The deprecated `Builder.add_ingredient_from_file_path` +method was removed as well. + +Migration: + +- Instead of `read_file(path, data_dir)`, use `Reader(path).json()`. +- Instead of `read_ingredient_file(path, data_dir)`, use + `Builder.add_ingredient(json, format, stream)` to add the ingredient directly. +- Instead of the `sign_file(...)` free function, use the `Builder.sign_file(source_path, dest_path, signer)` method (or `Builder.sign(...)` for stream-based signing). +- Instead of `Builder.add_ingredient_from_file_path(json, format, filepath)`, open the + file and call `Builder.add_ingredient_from_stream(json, format, stream)`. + +The `Builder.sign_file` method and `load_settings` are unaffected and remain available. + ## Version 0.6.0 diff --git a/pyproject.toml b/pyproject.toml index af237cc2..03d98d9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.33.0" +version = "0.34.0" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 5a5bfe78..bd581d58 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -32,7 +32,6 @@ ContextBuilder, ContextProvider, sdk_version, - read_ingredient_file, load_settings ) # NOQA @@ -52,6 +51,5 @@ 'ContextBuilder', 'ContextProvider', 'sdk_version', - 'read_ingredient_file', 'load_settings' ] diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index ae8b307e..a4fcdef5 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -38,8 +38,6 @@ 'c2pa_error', # Legacy APIs, deprecated 'c2pa_load_settings', - 'c2pa_read_file', - 'c2pa_read_ingredient_file', # Stream 'c2pa_create_stream', 'c2pa_release_stream', @@ -243,8 +241,13 @@ def __init__(self): @staticmethod def _free_native_ptr(ptr): - """Free a native pointer by casting it to c_void_p and calling c2pa_free.""" - _lib.c2pa_free(ctypes.cast(ptr, ctypes.c_void_p)) + """Free a native pointer by passing it to c2pa_free. + + c2pa_free's argtype is c_void_p, so ctypes converts any pointer + instance directly. (ctypes.cast(ptr, c_void_p) would do the same + conversion but leaves a reference cycle behind on every call.) + """ + _lib.c2pa_free(ptr) def _ensure_valid_state(self): """Raise if the resource is closed or uninitialized.""" @@ -645,12 +648,6 @@ def _setup_function(func, argtypes, restype=None): _setup_function(_lib.c2pa_signer_from_info, [ctypes.POINTER(C2paSignerInfo)], ctypes.POINTER(C2paSigner)) -_setup_function( - _lib.c2pa_read_file, [ - ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p) -_setup_function( - _lib.c2pa_read_ingredient_file, [ - ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p) # Set up Signer function prototypes _setup_function( @@ -889,33 +886,51 @@ def __init__(self): self._data_dir_str = "" +if sys.implementation.name == "cpython": + _PyMemoryView_FromMemory = ctypes.pythonapi.PyMemoryView_FromMemory + _PyMemoryView_FromMemory.restype = ctypes.py_object + _PyMemoryView_FromMemory.argtypes = ( + ctypes.c_void_p, ctypes.c_ssize_t, ctypes.c_int) + _PyBUF_WRITE = 0x200 + + def _write_buf(address, length): + return _PyMemoryView_FromMemory(address, length, _PyBUF_WRITE) +else: + def _write_buf(address, length): + return (ctypes.c_char * length).from_address(address) + + def _convert_to_py_string(value) -> str: if value is None: return "" py_string = "" - # Validate pointer before casting and freeing - if not isinstance(value, (int, ctypes.c_void_p)) or value == 0: + # Validate and normalize pointer before reading and freeing. + if isinstance(value, ctypes.c_void_p): + address = value.value + elif isinstance(value, int): + address = value + else: + return "" + if not address: return "" try: - ptr = ctypes.cast(value, ctypes.c_char_p) + raw = ctypes.string_at(address) - # Only if we got a valid pointer with valid content - if ptr and ptr.value is not None: + try: + py_string = raw.decode('utf-8', errors='strict') + except Exception: + py_string = "" + finally: + # Only free if we have a valid pointer try: - py_string = ptr.value.decode('utf-8', errors='strict') + _lib.c2pa_string_free(value) except Exception: - py_string = "" - finally: - # Only free if we have a valid pointer - try: - _lib.c2pa_string_free(value) - except Exception: - # Ignore clean up issues - pass - except (ctypes.ArgumentError, TypeError, ValueError): + # Ignore clean up issues + pass + except (ctypes.ArgumentError, TypeError, ValueError, OSError): # Invalid pointer type or value return "" @@ -1003,8 +1018,7 @@ def _parse_operation_result_for_error( if check_error: error = _lib.c2pa_error() if error: - error_str = ctypes.cast( - error, ctypes.c_char_p).value.decode('utf-8') + error_str = ctypes.string_at(error).decode('utf-8') _lib.c2pa_string_free(error) _raise_typed_c2pa_error(error_str) return None @@ -1191,208 +1205,6 @@ def _get_mime_type_from_path(path: Union[str, Path]) -> str: return mime_type -def read_ingredient_file( - path: Union[str, Path], data_dir: Union[str, Path]) -> str: - """Read a file as C2PA ingredient (deprecated). - This creates the JSON string that would be used as the ingredient JSON. - - .. deprecated:: 0.11.0 - This function is deprecated and will be removed in a future version. - To read C2PA metadata, use the :class:`c2pa.c2pa.Reader` class. - To add ingredients to a manifest, - use :meth:`c2pa.c2pa.Builder.add_ingredient` instead. - - Args: - path: Path to the file to read - data_dir: Directory to write binary resources to - - Returns: - The ingredient as a JSON string - - Raises: - C2paError: If there was an error reading the file - """ - warnings.warn( - "The read_ingredient_file function is deprecated and will be " - "removed in a future version. Please use Reader(path).json() for " - "reading C2PA metadata instead, or " - "Builder.add_ingredient(json, format, stream) to add ingredients " - "to a manifest.", - DeprecationWarning, - stacklevel=2, - ) - - _clear_error_state() - - container = _StringContainer() - - container._path_str = str(path).encode('utf-8') - container._data_dir_str = str(data_dir).encode('utf-8') - - result = _lib.c2pa_read_ingredient_file( - container._path_str, container._data_dir_str) - - _check_ffi_operation_result( - result, "Error reading ingredient file {}".format(path)) - - return _convert_to_py_string(result) - - -def read_file(path: Union[str, Path], - data_dir: Union[str, Path]) -> str: - """Read a C2PA manifest from a file (deprecated). - - .. deprecated:: 0.10.0 - This function is deprecated and will be removed in a future version. - To read C2PA metadata, use the :class:`c2pa.c2pa.Reader` class. - - Args: - path: Path to the file to read - data_dir: Directory to write binary resources to - - Returns: - The manifest as a JSON string - - Raises: - C2paError: If there was an error reading the file - """ - warnings.warn( - "The read_file function is deprecated and will be removed in a " - "future version. Please use the Reader class for reading C2PA " - "metadata instead.", - DeprecationWarning, - stacklevel=2, - ) - - _clear_error_state() - - container = _StringContainer() - - container._path_str = str(path).encode('utf-8') - container._data_dir_str = str(data_dir).encode('utf-8') - - result = _lib.c2pa_read_file(container._path_str, container._data_dir_str) - _check_ffi_operation_result( - result, "Error during read of manifest from file {}".format(path)) - - return _convert_to_py_string(result) - - -@overload -def sign_file( - source_path: Union[str, Path], - dest_path: Union[str, Path], - manifest: str, - signer_info: C2paSignerInfo, - return_manifest_as_bytes: bool = False -) -> Union[str, bytes]: - """Sign a file with a C2PA manifest using signer info. - """ - ... - - -@overload -def sign_file( - source_path: Union[str, Path], - dest_path: Union[str, Path], - manifest: str, - signer: 'Signer', - return_manifest_as_bytes: bool = False -) -> Union[str, bytes]: - """Sign a file with a C2PA manifest using a signer. - """ - ... - - -def sign_file( - source_path: Union[str, Path], - dest_path: Union[str, Path], - manifest: str, - signer_or_info: Union[C2paSignerInfo, 'Signer'], - return_manifest_as_bytes: bool = False -) -> Union[str, bytes]: - """Sign a file with a C2PA manifest (deprecated). - For now, this function is left here to provide a backwards-compatible API. - - .. deprecated:: 0.13.0 - This function is deprecated and will be removed in a future version. - Use :meth:`Builder.sign` instead. - - Args: - source_path: Path to the source file. We will attempt - to guess the mimetype of the source file based on - the extension. - dest_path: Path to write the signed file to - manifest: The manifest JSON string - signer_or_info: Either a signer configuration or a signer object - return_manifest_as_bytes: If True, return manifest bytes instead - of JSON string - - Returns: - The signed manifest as a JSON string or bytes, depending - on return_manifest_as_bytes - - Raises: - C2paError: If there was an error signing the file - C2paError.Encoding: If any of the string inputs contain - invalid UTF-8 characters - C2paError.NotSupported: If the file type cannot be determined - """ - - warnings.warn( - "The sign_file function is deprecated and will be removed in a " - "future version. Please use the Builder object and Builder.sign() " - "instead.", - DeprecationWarning, - stacklevel=2, - ) - - _clear_error_state() - - try: - # Determine if we have a signer or signer info - if isinstance(signer_or_info, C2paSignerInfo): - signer = Signer.from_info(signer_or_info) - own_signer = True - else: - signer = signer_or_info - own_signer = False - - # Create a builder from the manifest - builder = Builder(manifest) - - manifest_bytes = builder.sign_file( - source_path, - dest_path, - signer - ) - - if return_manifest_as_bytes: - return manifest_bytes - else: - # Read the signed manifest from the destination file - with Reader(dest_path) as reader: - return reader.json() - - except Exception as e: - # Clean up destination file if it exists and there was an error - if os.path.exists(dest_path): - try: - os.remove(dest_path) - except OSError: - logger.warning("Failed to remove destination file") - pass # Ignore cleanup errors - - # Re-raise the error - raise C2paError(f"Error signing file: {str(e)}") from e - finally: - # Ensure resources are cleaned up - if 'builder' in locals(): - builder.close() - if 'signer' in locals() and own_signer: - signer.close() - - class ContextProvider(ABC): """Abstract base class for types that provide a C2PA context. @@ -1827,10 +1639,21 @@ def read_callback(ctx, data, length): readinto = getattr(stream, "readinto", None) if readinto is not None: # Most streams have readinto - buf = (ctypes.c_char * length).from_address( - ctypes.addressof(data.contents)) - n = readinto(buf) - return n if n else 0 + buf = _write_buf( + ctypes.addressof(data.contents), length) + try: + n = readinto(buf) + finally: + release = getattr(buf, "release", None) + if release is not None: + release() + if not n: + return 0 + if n > length: + raise ValueError( + f"readinto returned {n} bytes but buffer length is {length}" + ) + return n # Fallback for streams without readinto. buffer = stream.read(length) @@ -3411,52 +3234,6 @@ def add_ingredient_from_stream( Builder._ERROR_MESSAGES['ingredient_error'].format("Unknown error"), check=lambda r: r != 0) - def add_ingredient_from_file_path( - self, - ingredient_json: Union[str, dict], - format: str, - filepath: Union[str, Path]): - """Add an ingredient from a file path to the builder (deprecated). - This is a legacy method. - - .. deprecated:: 0.13.0 - This method is deprecated and will be removed in a future version. - Use :meth:`add_ingredient` with a file stream instead. - - Args: - ingredient_json: The JSON ingredient definition - (either a JSON string or a dictionary) - format: The MIME type or extension of the ingredient - filepath: The path to the file containing the ingredient data - (can be a string or Path object) - - Raises: - C2paError: If there was an error adding the ingredient - C2paError.Encoding: If the ingredient JSON or format - contains invalid UTF-8 characters - FileNotFoundError: If the file at the specified path does not exist - """ - warnings.warn( - "add_ingredient_from_file_path is deprecated and will " - "be removed in a future version. Use add_ingredient " - "with a file stream instead.", - DeprecationWarning, - stacklevel=2, - ) - - try: - # Convert Path object to string if necessary - filepath_str = str(filepath) - - # Does the stream handling to use add_ingredient_from_stream - with open(filepath_str, 'rb') as file_stream: - self.add_ingredient_from_stream( - ingredient_json, format, file_stream) - except FileNotFoundError as e: - raise C2paError.FileNotFound(f"File not found: {filepath}") from e - except Exception as e: - raise C2paError.Other(f"Could not add ingredient: {e}") from e - def add_action(self, action_json: Union[str, dict]) -> None: """Add an action to the builder, that will be placed in the actions assertion array in the generated manifest. @@ -3991,9 +3768,6 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'Builder', 'Signer', 'load_settings', - 'read_file', - 'read_ingredient_file', - 'sign_file', 'format_embeddable', 'version', 'sdk_version' diff --git a/tests/perf/README.md b/tests/perf/README.md index 1f2ec022..cccc60de 100644 --- a/tests/perf/README.md +++ b/tests/perf/README.md @@ -186,6 +186,8 @@ A memory leak grows proportionally with work done. If you sign 50 images and get The baseline captures this expected static overhead. Future runs compare against it: if `leaked_bytes` grows beyond the baseline by more than 10%, the run fails. +The framework runs `gc.collect()` twice after the scenario finishes, while memray is still tracking. Without that sweep, objects sitting in not-yet-collected reference cycles would be counted in `leaked_bytes` and the number would depend on garbage collector timing rather than on actual leaks. With it, `leaked_bytes` means memory that is still allocated even though nothing in Python can reach it: true leaks plus the one-time static overhead described above. + ### How to confirm no leak exists? Run with a higher iteration count than default (100) and compare: diff --git a/tests/perf/baseline.json b/tests/perf/baseline.json index 302d648a..3e436967 100644 --- a/tests/perf/baseline.json +++ b/tests/perf/baseline.json @@ -2,139 +2,154 @@ "_meta": { "memray_version": "1.19.3", "python_version": "3.12.13", - "c2pa_native_version": "c2pa-v0.85.1", + "c2pa_native_version": "c2pa-v0.86.1", "iterations": 100, "perf_env": "python-3.12-slim", "arch": "aarch64" }, "reader_jpeg_legacy": { - "peak_bytes": 3814421, - "leaked_bytes": 3266116, - "total_allocations": 698899 + "peak_bytes": 3730321, + "leaked_bytes": 3236992, + "total_allocations": 717596 }, "reader_jpeg_with_context": { - "peak_bytes": 3822953, - "leaked_bytes": 3257471, - "total_allocations": 692953 + "peak_bytes": 3724412, + "leaked_bytes": 3229219, + "total_allocations": 711543 }, "reader_mp4": { - "peak_bytes": 4876441, - "leaked_bytes": 3257485, - "total_allocations": 2112991 + "peak_bytes": 4099225, + "leaked_bytes": 3228160, + "total_allocations": 2084373 }, "reader_wav": { - "peak_bytes": 5520266, - "leaked_bytes": 3267427, - "total_allocations": 400371 + "peak_bytes": 4399719, + "leaked_bytes": 3238102, + "total_allocations": 408059 }, "builder_sign_jpeg_legacy": { - "peak_bytes": 7695310, - "leaked_bytes": 3383623, - "total_allocations": 522425 + "peak_bytes": 7663441, + "leaked_bytes": 3352658, + "total_allocations": 555922 }, "builder_sign_jpeg_with_context": { - "peak_bytes": 7688236, - "leaked_bytes": 3376293, - "total_allocations": 516851 + "peak_bytes": 7656560, + "leaked_bytes": 3345863, + "total_allocations": 550105 }, "builder_sign_png_legacy": { - "peak_bytes": 7932767, - "leaked_bytes": 3383648, - "total_allocations": 1694629 + "peak_bytes": 7900956, + "leaked_bytes": 3351994, + "total_allocations": 1978914 }, "builder_sign_png_with_context": { - "peak_bytes": 7925490, - "leaked_bytes": 3376452, - "total_allocations": 1688908 + "peak_bytes": 7893973, + "leaked_bytes": 3345450, + "total_allocations": 1973003 }, "builder_sign_jpeg_parallel_split_pool": { - "peak_bytes": 45764159, - "leaked_bytes": 3818113, - "total_allocations": 528785 + "peak_bytes": 45726143, + "leaked_bytes": 3714796, + "total_allocations": 557891 }, "builder_sign_jpeg_parallel_split_barrier": { - "peak_bytes": 46225287, - "leaked_bytes": 3809216, - "total_allocations": 527412 + "peak_bytes": 45817488, + "leaked_bytes": 3780629, + "total_allocations": 627768 }, "builder_sign_png_parallel_split_pool": { - "peak_bytes": 46002549, - "leaked_bytes": 3817801, - "total_allocations": 1700731 + "peak_bytes": 40563556, + "leaked_bytes": 3746819, + "total_allocations": 1984928 }, "builder_sign_png_parallel_split_barrier": { - "peak_bytes": 45970433, - "leaked_bytes": 3812044, - "total_allocations": 1699396 + "peak_bytes": 45964496, + "leaked_bytes": 3745249, + "total_allocations": 1983686 }, "builder_sign_gif": { - "peak_bytes": 14544515, - "leaked_bytes": 3375865, - "total_allocations": 7183237 + "peak_bytes": 14514114, + "leaked_bytes": 3345545, + "total_allocations": 8547131 }, "builder_sign_heic": { - "peak_bytes": 4608484, - "leaked_bytes": 3376030, - "total_allocations": 771079 + "peak_bytes": 7717414, + "leaked_bytes": 3381771, + "total_allocations": 877927 }, "builder_sign_m4a": { - "peak_bytes": 18849082, - "leaked_bytes": 3376431, - "total_allocations": 2273497 + "peak_bytes": 18817771, + "leaked_bytes": 3345503, + "total_allocations": 2627261 }, "builder_sign_webp": { - "peak_bytes": 8900701, - "leaked_bytes": 3376432, - "total_allocations": 487683 + "peak_bytes": 8869451, + "leaked_bytes": 3345563, + "total_allocations": 496534 }, "builder_sign_avi": { - "peak_bytes": 7040387, - "leaked_bytes": 3376267, - "total_allocations": 40315553 + "peak_bytes": 7009007, + "leaked_bytes": 3345266, + "total_allocations": 45029611 }, "builder_sign_mp4": { - "peak_bytes": 6162851, - "leaked_bytes": 3376431, - "total_allocations": 1809672 + "peak_bytes": 6131977, + "leaked_bytes": 3345600, + "total_allocations": 1923444 }, "builder_sign_tiff": { - "peak_bytes": 13124728, - "leaked_bytes": 3376268, - "total_allocations": 5139967 + "peak_bytes": 13091168, + "leaked_bytes": 3345348, + "total_allocations": 5469122 }, "builder_sign_jpeg_parent_of": { - "peak_bytes": 14173992, - "leaked_bytes": 3377656, - "total_allocations": 1209933 + "peak_bytes": 14143351, + "leaked_bytes": 3345698, + "total_allocations": 1285766 }, "builder_sign_jpeg_component_of": { - "peak_bytes": 14175518, - "leaked_bytes": 3377891, - "total_allocations": 1232336 + "peak_bytes": 14144869, + "leaked_bytes": 3345779, + "total_allocations": 1308244 }, "builder_sign_jpeg_parent_and_component": { - "peak_bytes": 14530406, - "leaked_bytes": 3474418, - "total_allocations": 2160934 + "peak_bytes": 14434957, + "leaked_bytes": 3450621, + "total_allocations": 2289962 }, "builder_sign_jpeg_parent_and_component_mixed_mime": { - "peak_bytes": 14476171, - "leaked_bytes": 3378735, - "total_allocations": 2451587 + "peak_bytes": 14445750, + "leaked_bytes": 3345959, + "total_allocations": 2787986 }, "builder_sign_jpeg_two_components_same_mime": { - "peak_bytes": 14519270, - "leaked_bytes": 3473673, - "total_allocations": 2150782 + "peak_bytes": 14432257, + "leaked_bytes": 3442393, + "total_allocations": 2279745 }, "builder_sign_jpeg_two_components_mixed_mime": { - "peak_bytes": 14473127, - "leaked_bytes": 3377445, - "total_allocations": 2441195 + "peak_bytes": 14443122, + "leaked_bytes": 3346165, + "total_allocations": 2777653 }, "builder_sign_jpeg_archive_roundtrip": { - "peak_bytes": 14226832, - "leaked_bytes": 3426491, - "total_allocations": 1680290 + "peak_bytes": 14175766, + "leaked_bytes": 3365189, + "total_allocations": 1767740 + }, + "reader_error_no_manifest": { + "peak_bytes": 3443163, + "leaked_bytes": 3207515, + "total_allocations": 173242 + }, + "builder_error_invalid_manifest": { + "peak_bytes": 3243627, + "leaked_bytes": 3186717, + "total_allocations": 95461 + }, + "reader_string_apis": { + "peak_bytes": 3857136, + "leaked_bytes": 3229581, + "total_allocations": 1178409 } } \ No newline at end of file diff --git a/tests/perf/run_profile.py b/tests/perf/run_profile.py index 31593967..9b8d4651 100644 --- a/tests/perf/run_profile.py +++ b/tests/perf/run_profile.py @@ -58,6 +58,11 @@ def _run_scenario_under_memray(name: str, bin_path: Path) -> None: sys.path.insert(0, "{repo_root / 'src'}") from tests.perf.scenarios import SCENARIOS SCENARIOS["{name}"]({ITERATIONS}) +# Collect cycle garbage before tracking ends so leaked_bytes means +# "still allocated but unreachable" (true leaks + one-time statics). +import gc +gc.collect() +gc.collect() """ cmd = [ sys.executable, "-m", "memray", "run", diff --git a/tests/perf/scenarios.py b/tests/perf/scenarios.py index 0432aa20..e2945294 100644 --- a/tests/perf/scenarios.py +++ b/tests/perf/scenarios.py @@ -10,12 +10,20 @@ """ import io +import json import os import sys import threading from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from c2pa import Builder, C2paSignerInfo, Context, Reader, Signer +from c2pa import ( + Builder, + C2paError, + C2paSignerInfo, + Context, + Reader, + Signer, +) FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" READING_FIXTURES_DIR = FIXTURES_DIR / "files-for-reading-tests" @@ -452,7 +460,7 @@ def scenario_builder_sign_jpeg_archive_roundtrip(iterations: int = 100) -> None: archive = io.BytesIO() Builder(MANIFEST_BASE).to_archive(archive) archive.seek(0) - # from_archive() yields a context-less Builder; to keep the Context + # from_archive() yields a context-less Builder. To keep the Context # (and its signer), build with the context first, then load the archive. builder = Builder(MANIFEST_BASE, context=context).with_archive(archive) with io.BytesIO(ingredient_bytes) as ing: @@ -463,6 +471,46 @@ def scenario_builder_sign_jpeg_archive_roundtrip(iterations: int = 100) -> None: builder.sign("image/jpeg", io.BytesIO(source_bytes), io.BytesIO()) +def scenario_reader_error_no_manifest(iterations: int = 100) -> None: + """Reader on an unsigned asset: partial-init cleanup.""" + source_bytes = SOURCE_JPEG.read_bytes() # A.jpg carries no manifest + for _ in _iterate(iterations): + try: + Reader("image/jpeg", io.BytesIO(source_bytes)).json() + except C2paError: + pass + + +def scenario_builder_error_invalid_manifest(iterations: int = 100) -> None: + """Error case: Builder with malformed manifest JSON.""" + for _ in _iterate(iterations): + try: + Builder('{"not valid json') + except C2paError: + pass + + +def scenario_reader_string_apis(iterations: int = 100) -> None: + """Uncached string returns: detailed_json/crjson/remote_url/resource_to_stream.""" + source_bytes = SIGNED_JPEG.read_bytes() + context = Context() + # Resolve a real resource URI once, outside the measured loop. + probe = Reader("image/jpeg", io.BytesIO(source_bytes), + manifest_data=None, context=context) + manifests = json.loads(probe.json()) + active = manifests["manifests"][manifests["active_manifest"]] + thumb_uri = active["thumbnail"]["identifier"] + probe.close() + for _ in _iterate(iterations): + reader = Reader("image/jpeg", io.BytesIO(source_bytes), + manifest_data=None, context=context) + reader.detailed_json() + reader.crjson() + reader.get_remote_url() + reader.resource_to_stream(thumb_uri, io.BytesIO()) + reader.close() + + # jpeg + png context variants, paired with the `_legacy` scenarios above for # side-by-side comparison. @@ -524,6 +572,9 @@ def scenario_builder_sign_png_parallel_split_barrier(iterations: int = 100) -> N "builder_sign_jpeg_two_components_same_mime": scenario_builder_sign_jpeg_two_components_same_mime, "builder_sign_jpeg_two_components_mixed_mime": scenario_builder_sign_jpeg_two_components_mixed_mime, "builder_sign_jpeg_archive_roundtrip": scenario_builder_sign_jpeg_archive_roundtrip, + "reader_error_no_manifest": scenario_reader_error_no_manifest, + "builder_error_invalid_manifest": scenario_builder_error_invalid_manifest, + "reader_string_apis": scenario_reader_string_apis, } diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index ecdd1bc6..8b40f981 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -31,7 +31,7 @@ from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version, C2paBuilderIntent, C2paDigitalSourceType from c2pa import Settings, Context, ContextBuilder, ContextProvider -from c2pa.c2pa import Stream, LifecycleState, read_ingredient_file, read_file, sign_file, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable +from c2pa.c2pa import Stream, LifecycleState, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable PROJECT_PATH = os.getcwd() @@ -4787,9 +4787,6 @@ def test_stream_flush_after_close(self): class TestLegacyAPI(unittest.TestCase): def setUp(self): # Filter specific deprecation warnings for legacy API tests - warnings.filterwarnings("ignore", message="The read_file function is deprecated") - warnings.filterwarnings("ignore", message="The sign_file function is deprecated") - warnings.filterwarnings("ignore", message="The read_ingredient_file function is deprecated") warnings.filterwarnings("ignore", message="The create_signer function is deprecated") warnings.filterwarnings("ignore", message="The create_signer_from_info function is deprecated") warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") @@ -4863,489 +4860,6 @@ def tearDown(self): if os.path.exists(self.temp_data_dir): shutil.rmtree(self.temp_data_dir) - def test_read_ingredient_file(self): - """Test reading a C2PA ingredient from a file.""" - # Test reading ingredient from file with data_dir - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - - ingredient_json_with_dir = read_ingredient_file(self.testPath, temp_data_dir) - - # Verify some fields - ingredient_data = json.loads(ingredient_json_with_dir) - self.assertEqual(ingredient_data["title"], DEFAULT_TEST_FILE_NAME) - self.assertEqual(ingredient_data["format"], "image/jpeg") - self.assertIn("thumbnail", ingredient_data) - - def test_read_ingredient_file_who_has_no_manifest(self): - """Test reading a C2PA ingredient from a file.""" - # Test reading ingredient from file with data_dir - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - - # Load settings first, before they need to be used - load_settings('{"builder": { "thumbnail": {"enabled": false}}}') - - ingredient_json_with_dir = read_ingredient_file(self.testPath2, temp_data_dir) - - # Verify some fields - ingredient_data = json.loads(ingredient_json_with_dir) - self.assertEqual(ingredient_data["title"], INGREDIENT_TEST_FILE_NAME) - self.assertEqual(ingredient_data["format"], "image/jpeg") - self.assertNotIn("thumbnail", ingredient_data) - - # Reset setting - load_settings('{"builder": { "thumbnail": {"enabled": true}}}') - - def test_compare_read_ingredient_file_with_builder_added_ingredient(self): - """Test reading a C2PA ingredient from a file.""" - # Test reading ingredient from file with data_dir - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - - ingredient_json_with_dir = read_ingredient_file(self.testPath2, temp_data_dir) - - # Ingredient fields from read_ingredient_file - ingredient_data = json.loads(ingredient_json_with_dir) - - # Compare with ingredient added by Builder - builder = Builder.from_json(self.manifestDefinition) - # Only the title is needed (filename), since title not extracted or guessed from filename - ingredient_json = '{ "title" : "A.jpg" }' - with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - - # Get ingredient fields from signed manifest - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - active_manifest_id = manifest_data["active_manifest"] - active_manifest = manifest_data["manifests"][active_manifest_id] - only_ingredient = active_manifest["ingredients"][0] - - self.assertEqual(ingredient_data["title"], only_ingredient["title"]) - self.assertEqual(ingredient_data["format"], only_ingredient["format"]) - self.assertEqual(ingredient_data["document_id"], only_ingredient["document_id"]) - self.assertEqual(ingredient_data["instance_id"], only_ingredient["instance_id"]) - self.assertEqual(ingredient_data["relationship"], only_ingredient["relationship"]) - - def test_read_file(self): - """Test reading a C2PA ingredient from a file.""" - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - - # self.testPath has C2PA metadata to read - file_json_with_dir = read_file(self.testPath, temp_data_dir) - - # Parse the JSON and verify specific fields - file_data = json.loads(file_json_with_dir) - expected_manifest_id = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" - - # Verify some fields - self.assertEqual(file_data["active_manifest"], expected_manifest_id) - self.assertIn("manifests", file_data) - self.assertIn(expected_manifest_id, file_data["manifests"]) - - def test_sign_file(self): - """Test signing a file with C2PA manifest.""" - # Set up test paths - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - output_path = os.path.join(temp_data_dir, "signed_output.jpg") - - # Load test certificates and key - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - key = key_file.read() - - # Create signer info - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - - # Create a simple manifest - manifest = { - "claim_generator": "python_internals_test", - "claim_generator_info": [{ - "name": "python_internals_test", - "version": "0.0.1", - }], - # Claim version has become mandatory for signing v1 claims - "claim_version": 1, - "format": "image/jpeg", - "title": "Python Test Signed Image", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.opened" - } - ] - } - } - ] - } - - # Convert manifest to JSON string - manifest_json = json.dumps(manifest) - - try: - sign_file( - self.testPath, - output_path, - manifest_json, - signer_info - ) - - finally: - # Clean up - if os.path.exists(output_path): - os.remove(output_path) - - def test_sign_file_does_not_exist_errors(self): - """Test signing a file with C2PA manifest.""" - # Set up test paths - temp_data_dir = os.path.join(self.data_dir, "temp_data") - os.makedirs(temp_data_dir, exist_ok=True) - output_path = os.path.join(temp_data_dir, "signed_output.jpg") - - # Load test certificates and key - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - key = key_file.read() - - # Create signer info - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - - # Create a simple manifest - manifest = { - "claim_generator": "python_internals_test", - "claim_generator_info": [{ - "name": "python_internals_test", - "version": "0.0.1", - }], - # Claim version has become mandatory for signing v1 claims - "claim_version": 1, - "format": "image/jpeg", - "title": "Python Test Signed Image", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.opened" - } - ] - } - } - ] - } - - # Convert manifest to JSON string - manifest_json = json.dumps(manifest) - - try: - with self.assertRaises(Error): - sign_file( - "this-file-does-not-exist", - output_path, - manifest_json, - signer_info - ) - - finally: - # Clean up - if os.path.exists(output_path): - os.remove(output_path) - - def test_builder_sign_with_ingredient_from_file(self): - """Test Builder class operations with an ingredient added from file path.""" - - builder = Builder.from_json(self.manifestDefinition) - - # Test adding ingredient from file path - ingredient_json = '{"title": "Test Ingredient From File"}' - # Suppress the specific deprecation warning for this test, as this is a legacy method - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", self.testPath3) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) - - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient From File") - - builder.close() - - def test_builder_sign_with_ingredient_dict_from_file(self): - """Test Builder class operations with an ingredient added from file path using a dictionary.""" - - builder = Builder.from_json(self.manifestDefinition) - - # Test adding ingredient from file path with a dictionary - ingredient_dict = {"title": "Test Ingredient From File"} - # Suppress the specific deprecation warning for this test, as this is a legacy method - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - builder.add_ingredient_from_file_path(ingredient_dict, "image/jpeg", self.testPath3) - - with open(self.testPath2, "rb") as file: - output = io.BytesIO(bytearray()) - builder.sign(self.signer, "image/jpeg", file, output) - output.seek(0) - reader = Reader("image/jpeg", output) - json_data = reader.json() - manifest_data = json.loads(json_data) - - # Verify active manifest exists - self.assertIn("active_manifest", manifest_data) - active_manifest_id = manifest_data["active_manifest"] - - # Verify active manifest object exists - self.assertIn("manifests", manifest_data) - self.assertIn(active_manifest_id, manifest_data["manifests"]) - active_manifest = manifest_data["manifests"][active_manifest_id] - - # Verify ingredients array exists in active manifest - self.assertIn("ingredients", active_manifest) - self.assertIsInstance(active_manifest["ingredients"], list) - self.assertTrue(len(active_manifest["ingredients"]) > 0) - - # Verify the first ingredient's title matches what we set - first_ingredient = active_manifest["ingredients"][0] - self.assertEqual(first_ingredient["title"], "Test Ingredient From File") - - builder.close() - - def test_builder_add_ingredient_from_file_path(self): - """Test Builder class add_ingredient_from_file_path method.""" - - # Suppress the specific deprecation warning for this test, as this is a legacy method - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - - builder = Builder.from_json(self.manifestDefinition) - - # Test adding ingredient from file path - ingredient_json = '{"test": "ingredient_from_file_path"}' - builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", self.testPath) - - builder.close() - - def test_builder_add_ingredient_from_file_path_not_found(self): - """Test Builder class add_ingredient_from_file_path method.""" - - # Suppress the specific deprecation warning for this test, as this is a legacy method - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - - builder = Builder.from_json(self.manifestDefinition) - - # Test adding ingredient from file path - ingredient_json = '{"test": "ingredient_from_file_path"}' - - with self.assertRaises(Error.FileNotFound): - builder.add_ingredient_from_file_path(ingredient_json, "image/jpeg", "this-file-path-does-not-exist") - - def test_sign_file_using_callback_signer_overloads(self): - """Test signing a file using the sign_file function with a Signer object.""" - # Create a temporary directory for the test - temp_dir = tempfile.mkdtemp() - - try: - # Create a temporary output file path - output_path = os.path.join(temp_dir, "signed_output_callback.jpg") - - # Create signer with callback - signer = Signer.from_callback( - callback=self.callback_signer_es256, - alg=SigningAlg.ES256, - certs=self.certs.decode('utf-8'), - tsa_url="http://timestamp.digicert.com" - ) - - # Overload that returns a JSON string - result_json = sign_file( - self.testPath, - output_path, - self.manifestDefinition, - signer, - False - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path)) - - # Verify the result is JSON - self.assertIsInstance(result_json, str) - self.assertGreater(len(result_json), 0) - - manifest_data = json.loads(result_json) - self.assertIn("manifests", manifest_data) - self.assertIn("active_manifest", manifest_data) - - output_path_bytes = os.path.join(temp_dir, "signed_output_callback_bytes.jpg") - # Overload that returns bytes - result_bytes = sign_file( - self.testPath, - output_path_bytes, - self.manifestDefinition, - signer, - True - ) - - # Verify the output file was created - self.assertTrue(os.path.exists(output_path_bytes)) - - # Verify the result is bytes - self.assertIsInstance(result_bytes, bytes) - self.assertGreater(len(result_bytes), 0) - - # Read the signed file and verify the manifest contains expected content - with open(output_path, "rb") as file: - reader = Reader("image/jpeg", file) - file_manifest_json = reader.json() - self.assertIn("Python Test", file_manifest_json) - - finally: - shutil.rmtree(temp_dir) - - def test_sign_file_overloads(self): - """Test that the overloaded sign_file function works with both parameter types.""" - # Create a temporary directory for the test - temp_dir = tempfile.mkdtemp() - try: - # Test with C2paSignerInfo - output_path_1 = os.path.join(temp_dir, "signed_output_1.jpg") - - # Load test certificates and key - with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: - certs = cert_file.read() - with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: - key = key_file.read() - - # Create signer info - signer_info = C2paSignerInfo( - alg=b"es256", - sign_cert=certs, - private_key=key, - ta_url=b"http://timestamp.digicert.com" - ) - - # Test with C2paSignerInfo parameter - JSON return - result_1 = sign_file( - self.testPath, - output_path_1, - self.manifestDefinition, - signer_info, - False - ) - - self.assertIsInstance(result_1, str) - self.assertTrue(os.path.exists(output_path_1)) - - # Test with C2paSignerInfo parameter - bytes return - output_path_1_bytes = os.path.join(temp_dir, "signed_output_1_bytes.jpg") - result_1_bytes = sign_file( - self.testPath, - output_path_1_bytes, - self.manifestDefinition, - signer_info, - True - ) - - self.assertIsInstance(result_1_bytes, bytes) - self.assertTrue(os.path.exists(output_path_1_bytes)) - - # Test with Signer object - output_path_2 = os.path.join(temp_dir, "signed_output_2.jpg") - - # Create a signer from the signer info - signer = Signer.from_info(signer_info) - - # Test with Signer parameter - JSON return - result_2 = sign_file( - self.testPath, - output_path_2, - self.manifestDefinition, - signer, - False - ) - - self.assertIsInstance(result_2, str) - self.assertTrue(os.path.exists(output_path_2)) - - # Test with Signer parameter - bytes return - output_path_2_bytes = os.path.join(temp_dir, "signed_output_2_bytes.jpg") - result_2_bytes = sign_file( - self.testPath, - output_path_2_bytes, - self.manifestDefinition, - signer, - True - ) - - self.assertIsInstance(result_2_bytes, bytes) - self.assertTrue(os.path.exists(output_path_2_bytes)) - - # Both JSON results should be similar (same manifest structure) - manifest_1 = json.loads(result_1) - manifest_2 = json.loads(result_2) - - self.assertIn("manifests", manifest_1) - self.assertIn("manifests", manifest_2) - self.assertIn("active_manifest", manifest_1) - self.assertIn("active_manifest", manifest_2) - - # Both bytes results should be non-empty - self.assertGreater(len(result_1_bytes), 0) - self.assertGreater(len(result_2_bytes), 0) - - finally: - # Clean up the temporary directory - shutil.rmtree(temp_dir) - def test_sign_file_callback_signer_reports_error(self): """Test signing a file using the sign_file method with a callback that reports an error."""