From 5299d5bd0f58fca98d1fdad8f308f567f1af6fed Mon Sep 17 00:00:00 2001 From: Eric Scouten Date: Wed, 14 Oct 2026 11:35:44 -0700 Subject: [PATCH 1/2] ci: preflight against c2pa-rs main Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- c2pa-rs-preflight-ref.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 c2pa-rs-preflight-ref.txt diff --git a/c2pa-rs-preflight-ref.txt b/c2pa-rs-preflight-ref.txt new file mode 100644 index 00000000..ba2906d0 --- /dev/null +++ b/c2pa-rs-preflight-ref.txt @@ -0,0 +1 @@ +main From e07fdb7fe444bf7fe95bea14e7512f600b73d6c9 Mon Sep 17 00:00:00 2001 From: Eric Scouten Date: Fri, 16 Oct 2026 15:18:39 -0700 Subject: [PATCH 2/2] fix: preflight Python bindings against c2pa-rs 0.92 APIs Migrate default readers and builders to current context-based constructors, replace deleted typed frees, and compose manifests through a builder. Remove thread-local load_settings and migrate callers to explicit contexts. Update preflight version detection, run threading checks, and document the upcoming breaking change. Keep this work in the DO NOT MERGE preflight PR. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/test-c2pa-rs-source-build.yml | 11 +- docs/context-settings.md | 15 +- docs/intents.md | 11 +- docs/release-notes.md | 21 + docs/usage.md | 4 +- src/c2pa/__init__.py | 6 +- src/c2pa/c2pa.py | 248 ++++----- tests/perf/README.md | 2 +- tests/perf/scenarios.py | 2 +- tests/test_unit_tests.py | 470 +++++++++++------- tests/test_unit_tests_threaded.py | 2 +- 11 files changed, 428 insertions(+), 364 deletions(-) diff --git a/.github/workflows/test-c2pa-rs-source-build.yml b/.github/workflows/test-c2pa-rs-source-build.yml index a6c9ab2d..4efa7f80 100644 --- a/.github/workflows/test-c2pa-rs-source-build.yml +++ b/.github/workflows/test-c2pa-rs-source-build.yml @@ -123,7 +123,10 @@ jobs: working-directory: c2pa-python env: C2PA_PREFLIGHT_RUN: "1" - run: python ./tests/test_unit_tests.py + C2PA_RS_PATH: ${{ github.workspace }}/c2pa-rs + run: | + python ./tests/test_unit_tests.py + python ./tests/test_unit_tests_threaded.py tests-windows: name: Unit tests (Windows, ${{ matrix.runs-on }}) @@ -205,4 +208,8 @@ jobs: working-directory: c2pa-python env: C2PA_PREFLIGHT_RUN: "1" - run: python .\tests\test_unit_tests.py + C2PA_RS_PATH: ${{ github.workspace }}\c2pa-rs + run: | + python .\tests\test_unit_tests.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python .\tests\test_unit_tests_threaded.py diff --git a/docs/context-settings.md b/docs/context-settings.md index 35692a82..5a47cd3a 100644 --- a/docs/context-settings.md +++ b/docs/context-settings.md @@ -10,7 +10,7 @@ The `Context` class encapsulates configuration for: - **Signer configuration**: Optional signer credentials stored in the `Context` for reuse. - **State isolation**: Each `Context` is independent, allowing different configurations to coexist in the same application. -`Context` replaces the deprecated global `load_settings()` function with explicit, isolated configuration: +`Context` replaces the removed thread-local `load_settings()` function with explicit, isolated configuration: - **Makes dependencies explicit**: Configuration is passed directly to `Reader` and `Builder`, not hidden in global state. - **Enables multiple configurations**: Run different configurations simultaneously (for example, one for development with test certificates, another for production with strict validation). @@ -18,7 +18,7 @@ The `Context` class encapsulates configuration for: - **Simplifies testing**: Create isolated configurations for tests without worrying about cleanup or interference. > [!NOTE] -> The deprecated `load_settings()` function still works for backward compatibility, but you are encouraged to migrate your code to use `Context`. See [Migrating from load_settings](#migrating-from-load_settings). +> c2pa-rs 0.92 no longer supports thread-local settings, and `load_settings()` has been removed from these bindings. See [Migrating from load_settings](#migrating-from-load_settings). ## Quick start @@ -754,7 +754,7 @@ assert isinstance(ctx, ContextProvider) # True ## Migrating from load_settings -The `load_settings()` function is deprecated. Replace it with `Settings` and `Context` APIs: +The `load_settings()` function has been removed. Replace it with `Settings` and `Context` APIs, passing the context to every operation that needs the configuration: | Aspect | `load_settings` (legacy) | `Context` | |--------|--------------------------|-----------| @@ -762,7 +762,7 @@ The `load_settings()` function is deprecated. Replace it with `Settings` and `Co | Multiple configs | Not supported | One context per configuration | | Testing | Shared global state | Isolated contexts per test | -**Deprecated:** +**Old approach (no longer supported):** ```py from c2pa import load_settings, Reader @@ -774,10 +774,11 @@ reader = Reader("image.jpg") # uses global settings **Current approach:** ```py -from c2pa import Settings, Context, Reader +from c2pa import Context, Reader -ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) -reader = Reader("image.jpg", context=ctx) +with Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) as ctx: + with Reader("image.jpg", context=ctx) as reader: + print(reader.json()) ``` ## See also diff --git a/docs/intents.md b/docs/intents.md index bd970185..7b95c8ba 100644 --- a/docs/intents.md +++ b/docs/intents.md @@ -51,7 +51,7 @@ You can set the intent on a `Builder` instance by: - [Using Context](#using-context) - [Using `set_intent` on the `Builder`](#using-set_intent-on-the-builder) -Don't set the intent using the deprecated `load_settings()` function. For existing code, see [Context and settings - Migrating from load_settings](context-settings.md#migrating-from-load_settings). +The thread-local `load_settings()` function has been removed. For existing code, see [Context and settings - Migrating from load_settings](context-settings.md#migrating-from-load_settings). ### Using Context @@ -114,11 +114,7 @@ flowchart TD Check --> |No| CheckCtx{Was a Context with builder.intent provided?} CheckCtx --> |Yes| UseCtx["Use Context intent"] - CheckCtx --> |No| CheckGlobal{Was load_settings called - with builder.intent?} - CheckGlobal --> |Yes| UseGlobal["Use global intent - (deprecated)"] - CheckGlobal --> |No| NoIntent["No intent set. + CheckCtx --> |No| NoIntent["No intent set. Caller must define actions manually in manifest JSON."] ``` @@ -173,8 +169,7 @@ The `Builder` intent controls what the `Builder` does with the source stream (so ```mermaid flowchart TD Intent["Intent - (via Context, set_intent, - or load_settings)"] --> Q{Intent type?} + (via Context or set_intent)"] --> Q{Intent type?} Q --> |CREATE| CreateFlow["No parent allowed Source stream is new content"] Q --> |EDIT or UPDATE| EditFlow{Was a parentOf ingredient diff --git a/docs/release-notes.md b/docs/release-notes.md index f8f09717..3e4f98d1 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,26 @@ # Release notes +## Unreleased: c2pa-rs 0.92 preflight (DO NOT MERGE) + +This branch tests the upcoming native SDK, not a published Python release. +The normal native release pin is unchanged. + +### Breaking changes + +Removed `load_settings()`: c2pa-rs 0.92 removes thread-local settings. +Create a `Context` with `Settings` (or `Context.from_dict` / `from_json`) +and pass it explicitly to every `Reader` and `Builder` that needs that +configuration. Constructors without a context continue to use SDK defaults. +See [Migrating from load_settings](context-settings.md#migrating-from-load_settings). + +Reader and Builder constructors now use the current context-based C APIs. +Native strings, manifests, and signatures are released through `c2pa_free`. +`format_embeddable()` retains its `(size, bytes)` result using a temporary +default-context builder. For custom settings or format-specific builder state, +use `Builder.compose_manifest(format, manifest_bytes)` on a live builder. +Signing closes the builder, so use a separate builder for composition after +`sign()`. + ## Version 0.33.0 ### Breaking changes diff --git a/docs/usage.md b/docs/usage.md index c7754e72..9d95682c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -52,7 +52,7 @@ manifest_json = json.dumps({ ## Settings, Context, and ContextProvider -The `Settings` and `Context` classes provide per-instance configuration for `Reader` and `Builder` operations, replacing the global `load_settings()` function, which is now deprecated. +The `Settings` and `Context` classes provide per-instance configuration for `Reader` and `Builder` operations, replacing the removed thread-local `load_settings()` function. See [Context and settings](context-settings.md) for details. @@ -144,7 +144,7 @@ The built-in `Context` class is the standard `ContextProvider` implementation. C ### Migrating from load_settings -The `load_settings()` function is deprecated. Replace it with `Settings` and `Context`. See [Migrating from load_settings](context-settings.md#migrating-from-load_settings) for details. +The `load_settings()` function has been removed for c2pa-rs 0.92 compatibility. Replace it with `Settings` and `Context`. See [Migrating from load_settings](context-settings.md#migrating-from-load_settings) for details. ## File-based operation diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index bd581d58..50e38dce 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -31,8 +31,7 @@ Context, ContextBuilder, ContextProvider, - sdk_version, - load_settings + sdk_version ) # NOQA # Re-export C2paError and its subclasses @@ -50,6 +49,5 @@ 'Context', 'ContextBuilder', 'ContextProvider', - 'sdk_version', - 'load_settings' + 'sdk_version' ] diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index eca9e952..dc134edf 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -42,14 +42,11 @@ 'c2pa_version', # Error retriever and parser 'c2pa_error', - # Legacy APIs, deprecated - 'c2pa_load_settings', # Stream 'c2pa_create_stream', 'c2pa_release_stream', # Reader bindings - 'c2pa_reader_from_stream', - 'c2pa_reader_from_manifest_data_and_stream', + 'c2pa_reader_new', 'c2pa_reader_json', 'c2pa_reader_detailed_json', 'c2pa_reader_crjson', @@ -62,8 +59,6 @@ 'c2pa_reader_remote_url', 'c2pa_reader_supported_mime_types', # Builder bindings - 'c2pa_builder_from_json', - 'c2pa_builder_from_archive', 'c2pa_builder_set_no_embed', 'c2pa_builder_set_remote_url', 'c2pa_builder_set_intent', @@ -79,13 +74,12 @@ 'c2pa_builder_with_definition', 'c2pa_builder_with_archive', 'c2pa_builder_supported_mime_types', - 'c2pa_format_embeddable', + 'c2pa_builder_compose_manifest', # Signer bindings 'c2pa_signer_create', 'c2pa_signer_from_info', 'c2pa_signer_reserve_size', 'c2pa_ed25519_sign', - 'c2pa_signature_free', # Settings bindings 'c2pa_settings_new', 'c2pa_settings_set_value', @@ -97,9 +91,7 @@ 'c2pa_context_builder_set_signer', 'c2pa_context_new', # Free bindings - 'c2pa_string_free', 'c2pa_free_string_array', - 'c2pa_manifest_bytes_free', 'c2pa_free', ] @@ -1120,7 +1112,7 @@ def _read_native_error() -> Optional[str]: try: message = ctypes.string_at(error).decode('utf-8') finally: - _lib.c2pa_string_free(error) + _lib.c2pa_free(error) _write_no_error_marker() if not message or _is_no_error_marker(message): @@ -1311,10 +1303,6 @@ def _setup_function(func, argtypes, restype=None): # Set up function prototypes not attached to an API object _setup_function(_lib.c2pa_version, [], ctypes.c_void_p) _setup_function(_lib.c2pa_error, [], ctypes.c_void_p) -_setup_function(_lib.c2pa_string_free, [ctypes.c_void_p], None) -_setup_function( - _lib.c2pa_load_settings, [ - ctypes.c_char_p, ctypes.c_char_p], ctypes.c_int) _setup_function( _lib.c2pa_free_string_array, [ctypes.POINTER(ctypes.c_char_p), ctypes.c_size_t], @@ -1322,13 +1310,7 @@ def _setup_function(func, argtypes, restype=None): ) # Set up Reader function prototypes -_setup_function(_lib.c2pa_reader_from_stream, - [ctypes.c_char_p, ctypes.POINTER(C2paStream)], - ctypes.POINTER(C2paReader)) -_setup_function( - _lib.c2pa_reader_from_manifest_data_and_stream, [ - ctypes.c_char_p, ctypes.POINTER(C2paStream), ctypes.POINTER( - ctypes.c_ubyte), ctypes.c_size_t], ctypes.POINTER(C2paReader)) +_setup_function(_lib.c2pa_reader_new, [], ctypes.POINTER(C2paReader)) _setup_function( _lib.c2pa_reader_json, [ ctypes.POINTER(C2paReader)], ctypes.c_void_p) @@ -1357,12 +1339,6 @@ def _setup_function(func, argtypes, restype=None): ) # Set up Builder function prototypes -_setup_function( - _lib.c2pa_builder_from_json, [ - ctypes.c_char_p], ctypes.POINTER(C2paBuilder)) -_setup_function(_lib.c2pa_builder_from_archive, - [ctypes.POINTER(C2paStream)], - ctypes.POINTER(C2paBuilder)) _setup_function( _lib.c2pa_builder_with_archive, [ctypes.POINTER(C2paBuilder), ctypes.POINTER(C2paStream)], @@ -1408,12 +1384,8 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], ctypes.c_int64) _setup_function( - _lib.c2pa_manifest_bytes_free, [ - ctypes.POINTER( - ctypes.c_ubyte)], None) -_setup_function( - _lib.c2pa_format_embeddable, [ - ctypes.c_char_p, ctypes.POINTER( + _lib.c2pa_builder_compose_manifest, [ + ctypes.POINTER(C2paBuilder), ctypes.c_char_p, ctypes.POINTER( ctypes.c_ubyte), ctypes.c_size_t, ctypes.POINTER( ctypes.POINTER( ctypes.c_ubyte))], ctypes.c_int64) @@ -1437,10 +1409,6 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER( ctypes.c_ubyte), ctypes.c_size_t, ctypes.c_char_p], ctypes.POINTER( ctypes.c_ubyte)) -_setup_function( - _lib.c2pa_signature_free, [ - ctypes.POINTER( - ctypes.c_ubyte)], None) _setup_function( _lib.c2pa_builder_supported_mime_types, [ctypes.POINTER(ctypes.c_size_t)], @@ -1532,7 +1500,7 @@ def _learn_no_error_marker_text(): try: text = ctypes.string_at(raw).decode('utf-8') finally: - _lib.c2pa_string_free(raw) + _lib.c2pa_free(raw) if not text: logger.warning( "c2pa: error-slot marker not set, some errors may be stale") @@ -1754,14 +1722,14 @@ def _convert_to_py_string(value) -> str: finally: # Only free if we have a valid pointer try: - _lib.c2pa_string_free(value) + _lib.c2pa_free(value) except Exception: # Ignore clean up issues pass except (ctypes.ArgumentError, TypeError, ValueError, OSError): # Invalid pointer type or value, gracefully handled by native lib. try: - _lib.c2pa_string_free(value) + _lib.c2pa_free(value) except Exception: pass return "" @@ -1967,67 +1935,6 @@ def version() -> str: return _convert_to_py_string(result) -@overload -def load_settings(settings: str, format: str = "json") -> None: - ... - - -@overload -def load_settings(settings: dict) -> None: - ... - - -def load_settings(settings: Union[str, dict], format: str = "json") -> None: - """Load C2PA settings into thread-local storage from a string or dict. - - .. deprecated:: - Use :class:`Settings` and :class:`Context` for - per-instance configuration instead. Settings and - Context will propagate configurations through object instances, - no thread-local configurations. Avoid mixing Context APIs - and legacy load_settings usage. - - Args: - settings: The settings string or dict to load - format: The format of the settings string (default: "json"). - Ignored when settings is a dict. - - Raises: - C2paError: If there was an error loading the settings - """ - warnings.warn( - "load_settings() is deprecated. Use Settings" - " and Context for per-instance configuration.", - DeprecationWarning, - stacklevel=2, - ) - - # Convert to JSON string as necessary - try: - if isinstance(settings, dict): - settings_str = json.dumps(settings) - format = "json" - else: - settings_str = settings - except (TypeError, ValueError) as e: - raise C2paError(f"Failed to serialize settings to JSON: {e}") - - try: - _check_cstr_arg("settings", settings_str) - _check_cstr_arg("format", format) - settings_bytes = settings_str.encode('utf-8') - format_bytes = format.encode('utf-8') - except (AttributeError, UnicodeEncodeError) as e: - raise C2paError(f"Failed to encode settings to UTF-8: {e}") - - with _native_section(): - result = _lib.c2pa_load_settings(settings_bytes, format_bytes) - _check_ffi_operation_result( - result, - "Error loading settings", - check=lambda r: r != 0) - - @contextlib.contextmanager def _context_guard(context): """Hold a caller-supplied context valid across a native call. @@ -3151,10 +3058,12 @@ def _create_reader(self, format_bytes, stream_obj, manifest_data: Optional manifest bytes """ format_arg = _format_ffi_arg(format_bytes) + self._create_and_activate( + _lib.c2pa_reader_new, Reader._ERROR_MESSAGES['reader_error']) if manifest_data is None: - def create(): - return _lib.c2pa_reader_from_stream( - format_arg, stream_obj._stream) + def configure(handle): + return _lib.c2pa_reader_with_stream( + handle, format_arg, stream_obj._stream) else: if not isinstance(manifest_data, bytes): raise TypeError(Reader._ERROR_MESSAGES['manifest_error']) @@ -3162,16 +3071,21 @@ def create(): ctypes.c_ubyte * len(manifest_data)).from_buffer_copy(manifest_data) - def create(): - return _lib.c2pa_reader_from_manifest_data_and_stream( + def configure(handle): + return _lib.c2pa_reader_with_manifest_data_and_stream( + handle, format_arg, stream_obj._stream, manifest_array, len(manifest_data), ) - self._create_and_activate( - create, Reader._ERROR_MESSAGES['reader_error']) + _check_cstr_arg('format', format_arg) + _check_handle_arg('stream', stream_obj._stream) + if manifest_data is not None: + _check_bytes_arg('manifest_data', manifest_data) + self._consume_and_swap( + configure, Reader._ERROR_MESSAGES['reader_error']) def _init_from_file(self, path, format_bytes, manifest_data=None): @@ -4027,24 +3941,22 @@ def from_archive( C2paError: If there was an error creating the builder from archive """ - stream_obj = Stream(stream) - - try: + with Context() as context: with _native_section(): - handle = _lib.c2pa_builder_from_archive(stream_obj._stream) - + handle = _lib.c2pa_builder_from_context( + context.execution_context) _check_ffi_operation_result( handle, "Failed to create builder from archive") - - try: - # A builder from an archive here carries no context. - return cls._wrap_native_handle(handle) - except Exception: - # No instance took ownership, so the handle is still ours. - ManagedResource._free_native_ptr(handle) - raise - finally: - stream_obj.close() + try: + builder = cls._wrap_native_handle(handle) + except Exception: + ManagedResource._free_native_ptr(handle) + raise + try: + return builder.with_archive(stream) + except Exception: + builder.close() + raise @overload def __init__( @@ -4090,9 +4002,8 @@ def __init__( if context is not None: self._init_from_context(context, json_str) else: - self._create_and_activate( - lambda: _lib.c2pa_builder_from_json(json_str), - Builder._ERROR_MESSAGES['builder_error']) + with Context() as default_context: + self._init_from_context(default_context, json_str) def _init_from_context(self, context, json_str): """Initialize Builder from a ContextProvider. @@ -4411,6 +4322,43 @@ def with_archive(self, stream: Any) -> 'Builder': return self + def compose_manifest( + self, format: str, manifest_bytes: bytes, + ) -> tuple[int, bytes]: + """Convert raw manifest bytes into an embeddable representation. + + Uses this builder's context and format-specific state. Call before + closing the builder; signing closes a Builder. + + Returns: + A tuple of (size, embeddable manifest bytes). + + Raises: + C2paError: If the builder is closed or composition fails. + """ + _check_cstr_arg("format", format) + _check_bytes_arg("manifest_bytes", manifest_bytes) + format_bytes = format.encode('utf-8') + manifest_array = ( + ctypes.c_ubyte * len(manifest_bytes)).from_buffer_copy( + manifest_bytes) + result_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() + with self._native_call(): + result = _lib.c2pa_builder_compose_manifest( + self._handle, format_bytes, manifest_array, + len(manifest_bytes), ctypes.byref(result_bytes_ptr)) + try: + _check_ffi_operation_result( + result, "Failed to format embeddable manifest", + check=lambda r: r < 0) + if not result_bytes_ptr: + raise C2paError( + "Failed to format embeddable manifest: no data returned") + return result, ctypes.string_at(result_bytes_ptr, result) + finally: + if result_bytes_ptr: + _lib.c2pa_free(result_bytes_ptr) + def _sign_internal( self, format: str, @@ -4497,7 +4445,7 @@ def _sign_internal( # Always free the C-allocated memory, # even if we failed to copy manifest bytes try: - _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) + _lib.c2pa_free(manifest_bytes_ptr) except Exception: logger.error( "Failed to release native manifest bytes memory" @@ -4698,7 +4646,10 @@ def sign_file( def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: - """Convert a binary C2PA manifest into an embeddable version. + """Convert a binary C2PA manifest using a default-context builder. + + For a builder with custom settings or format-specific state, use + :meth:`Builder.compose_manifest` on that builder instead. Args: format: The MIME type or extension of the target format @@ -4710,40 +4661,8 @@ def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: Raises: C2paError: If there was an error converting the manifest """ - _check_cstr_arg("format", format) - format_str = format.encode('utf-8') - manifest_array = (ctypes.c_ubyte * len(manifest_bytes)).from_buffer_copy( - manifest_bytes - ) - result_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() - - with _native_section(): - result = _lib.c2pa_format_embeddable( - format_str, - manifest_array, - len(manifest_bytes), - ctypes.byref(result_bytes_ptr) - ) - - _check_ffi_operation_result( - result, - "Failed to format embeddable manifest", - check=lambda r: r < 0) - - size = result - if not result_bytes_ptr: - raise C2paError( - "Failed to format embeddable manifest: no data returned") - try: - result_bytes = ctypes.string_at(result_bytes_ptr, size) - except Exception as e: - raise C2paError( - f"Failed to convert embeddable manifest bytes: {e}" - ) from e - finally: - _lib.c2pa_manifest_bytes_free(result_bytes_ptr) - - return size, result_bytes + with Builder({}) as builder: + return builder.compose_manifest(format, manifest_bytes) def create_signer( @@ -4868,7 +4787,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: # Ed25519 signatures are always 64 bytes signature = bytes(signature_ptr[:64]) finally: - _lib.c2pa_signature_free(signature_ptr) + _lib.c2pa_free(signature_ptr) return signature @@ -4893,7 +4812,6 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'Reader', 'Builder', 'Signer', - 'load_settings', 'format_embeddable', 'version', 'sdk_version' diff --git a/tests/perf/README.md b/tests/perf/README.md index a9a87a93..996838b4 100644 --- a/tests/perf/README.md +++ b/tests/perf/README.md @@ -33,7 +33,7 @@ Each scenario loops multiple times so leaks accumulate and become visible in the make memory-use-bench MEMRAY_ITERATIONS=1000 ``` -Most scenarios use the Context API: they build a `Context` once and reuse it across iterations, so its settings are parsed a single time. The jpeg and png cases also keep a `_legacy` variant that builds the `Reader`/`Builder` without a `Context`, which re-reads the thread-local settings on each construction. Running a pair (for example `builder_sign_jpeg_legacy` and `builder_sign_jpeg_with_context`) compares the two paths. +Most scenarios use the Context API: they build a `Context` once and reuse it across iterations, so its settings are parsed a single time. The jpeg and png cases also keep a `_legacy` variant that builds the `Reader`/`Builder` without an explicit `Context`, creating a fresh default context on each construction. Running a pair (for example `builder_sign_jpeg_legacy` and `builder_sign_jpeg_with_context`) compares the two paths. With c2pa-rs 0.92, neither path uses thread-local settings. The `builder_sign_{jpeg,png}_parallel_*` scenarios build one `Context` and share it across 10 threads that sign concurrently, each with its own streams and `Builder`. The name encodes two axes. `split` divides the iteration budget across the threads, so total work matches a single-threaded scenario; `full` runs the full loop on each of the 10 threads, so total work is 10x (use these with `SCENARIO=` rather than the whole suite). `pool` runs the threads through a `ThreadPoolExecutor`; `barrier` starts all 10 at once with a `threading.Barrier`. diff --git a/tests/perf/scenarios.py b/tests/perf/scenarios.py index 9510f60f..3263fb01 100644 --- a/tests/perf/scenarios.py +++ b/tests/perf/scenarios.py @@ -140,7 +140,7 @@ def _read_file(path: Path, mime: str, iterations: int) -> None: # Context-API helpers: the Context is built once before the loop and reused on # every iteration, so its settings are parsed a single time. Most scenarios use # these. The `_legacy` jpeg/png scenarios build the Reader/Builder without a -# Context, which re-reads thread-local settings on each construction; running a +# Context, which creates a fresh default context on each construction; running a # legacy scenario against its `_with_context` pair isolates the settings cost. def _sign_file_context(path: Path, mime: str, iterations: int) -> None: diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index dd3c3ec3..6ebbec04 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -40,7 +40,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, ManagedResource, load_settings, create_signer, create_signer_from_info, ed25519_sign, format_embeddable, _get_mime_type_from_path, _encode_format, _format_ffi_arg +from c2pa.c2pa import Stream, LifecycleState, ManagedResource, create_signer, create_signer_from_info, ed25519_sign, format_embeddable, _get_mime_type_from_path, _encode_format, _format_ffi_arg import c2pa.c2pa as c2pa_module from pathlib import Path @@ -73,9 +73,9 @@ def _mock(*args): def load_test_settings_json(): """ - Load default (legacy) trust configuration test settings from a + Load trust configuration test settings from a JSON config file and return its content as JSON-compatible dict. - The return value is used to load settings (thread_local) in tests. + The return value configures explicit Settings and Context instances. Returns: dict: The parsed JSON content as a Python dictionary (JSON-compatible). @@ -99,22 +99,22 @@ def parse_native_version(): """ Parse the expected native SDK version. - Reads c2pa-rs-preflight-ref.txt instead of c2pa-native-version.txt when - C2PA_PREFLIGHT_RUN is set: that flag is set only by - test-c2pa-rs-source-build.yml's own "Run tests" step, because the - presence of c2pa-rs-preflight-ref.txt in the checked-out tree isn't by - itself proof of anything -- an ordinary build.yml run on a branch that - happens to carry that file (e.g. this PR) still downloads and installs - the real pinned release, not the preflight ref. + For source-build preflights, read the version from the checked-out + Rust package, not its git ref (which may be a branch or commit). + Ordinary builds still use c2pa-native-version.txt. Returns: str: The semantic version string (e.g. "0.85.2"). """ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if os.environ.get('C2PA_PREFLIGHT_RUN'): - path = os.path.join(repo_root, 'c2pa-rs-preflight-ref.txt') - else: - path = os.path.join(repo_root, 'c2pa-native-version.txt') + manifest = Path(os.environ['C2PA_RS_PATH']) / 'Cargo.toml' + metadata = json.loads(subprocess.check_output( + ['cargo', 'metadata', '--no-deps', '--format-version', '1', + '--manifest-path', str(manifest)], text=True)) + return next(package['version'] for package in metadata['packages'] + if package['name'] == 'c2pa-c-ffi') + path = os.path.join(repo_root, 'c2pa-native-version.txt') with open(path, 'r') as f: raw = f.read().strip() # Strip the "c2pa-v" / "c2pa-rc-v" prefix to get the bare semantic version. @@ -126,6 +126,20 @@ def test_sdk_version(self): # This test verifies the native libraries used match the expected version. self.assertIn(parse_native_version(), sdk_version()) + def test_preflight_version_comes_from_rust_package_not_git_ref(self): + metadata = {'packages': [ + {'name': 'c2pa', 'version': '0.92.0-dev'}, + {'name': 'c2pa-c-ffi', 'version': '0.92.0-dev'}, + ]} + with patch.dict(os.environ, { + 'C2PA_PREFLIGHT_RUN': '1', 'C2PA_RS_PATH': '/source/c2pa-rs', + }), patch.object(subprocess, 'check_output', + return_value=json.dumps(metadata)) as command: + self.assertEqual(parse_native_version(), '0.92.0-dev') + self.assertIn('--no-deps', command.call_args.args[0]) + self.assertIn(str(Path('/source/c2pa-rs') / 'Cargo.toml'), + command.call_args.args[0]) + class TestIsReadableStream(unittest.TestCase): """ A stream is anything Stream will accept (read/write/seek/tell/flush). @@ -296,7 +310,6 @@ def test_literal_none_string_passes_through(self): class TestReader(unittest.TestCase): def setUp(self): - warnings.filterwarnings("ignore", message="load_settings\\(\\) is deprecated") self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE @@ -411,7 +424,7 @@ def test_stream_read_get_validation_state(self): self.assertEqual(validation_state, "Valid") def test_stream_read_get_validation_state_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings + # Exercise explicit settings on a separate thread result = {} exception = {} @@ -420,13 +433,15 @@ def read_with_trust_config(): # Load trust configuration settings_dict = load_test_settings_json() - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) + # Pass the trust configuration explicitly to each operation. + settings = Settings() + self.addCleanup(settings.close) + settings.update(settings_dict) + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) with open(self.testPath, "rb") as file: - reader = Reader("image/jpeg", file) + reader = Reader("image/jpeg", file, context=sdk_context) validation_state = reader.get_validation_state() result['validation_state'] = validation_state except Exception as e: @@ -2221,7 +2236,7 @@ def test_streams_sign_with_es256_alg_edit_intent(self): output.close() def test_streams_sign_with_es256_alg_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings + # Exercise explicit settings on a separate thread result = {} exception = {} @@ -2230,17 +2245,19 @@ def sign_and_validate_with_trust_config(): # Load trust configuration settings_dict = load_test_settings_json() - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) + # Pass the trust configuration explicitly to each operation. + settings = Settings() + self.addCleanup(settings.close) + settings.update(settings_dict) + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) + builder = Builder(self.manifestDefinitionV2, context=sdk_context) output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() # Get validation state with trust config @@ -2295,7 +2312,7 @@ def test_sign_with_ed25519_alg(self): output.close() def test_sign_with_ed25519_alg_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings + # Exercise explicit settings on a separate thread result = {} exception = {} @@ -2304,10 +2321,12 @@ def sign_and_validate_with_trust_config(): # Load trust configuration settings_dict = load_test_settings_json() - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) + # Pass the trust configuration explicitly to each operation. + settings = Settings() + self.addCleanup(settings.close) + settings.update(settings_dict) + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) with open(os.path.join(self.data_dir, "ed25519.pub"), "rb") as cert_file: certs = cert_file.read() @@ -2323,11 +2342,11 @@ def sign_and_validate_with_trust_config(): signer = Signer.from_info(signer_info) with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) + builder = Builder(self.manifestDefinitionV2, context=sdk_context) output = io.BytesIO(bytearray()) builder.sign(signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() # Get validation state with trust config @@ -2435,7 +2454,7 @@ def test_sign_with_ps256_alg_2(self): output.close() def test_sign_with_ps256_alg_2_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings + # Exercise explicit settings on a separate thread result = {} exception = {} @@ -2444,10 +2463,12 @@ def sign_and_validate_with_trust_config(): # Load trust configuration settings_dict = load_test_settings_json() - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) + # Pass the trust configuration explicitly to each operation. + settings = Settings() + self.addCleanup(settings.close) + settings.update(settings_dict) + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) with open(os.path.join(self.data_dir, "ps256.pub"), "rb") as cert_file: certs = cert_file.read() @@ -2463,11 +2484,11 @@ def sign_and_validate_with_trust_config(): signer = Signer.from_info(signer_info) with open(self.testPath2, "rb") as file: - builder = Builder(self.manifestDefinitionV2) + builder = Builder(self.manifestDefinitionV2, context=sdk_context) output = io.BytesIO(bytearray()) builder.sign(signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() # Get validation state with trust config @@ -2513,7 +2534,7 @@ def test_archive_sign(self): output.close() def test_archive_sign_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings + # Exercise explicit settings on a separate thread result = {} exception = {} @@ -2522,20 +2543,22 @@ def sign_and_validate_with_trust_config(): # Load trust configuration settings_dict = load_test_settings_json() - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) + # Pass the trust configuration explicitly to each operation. + settings = Settings() + self.addCleanup(settings.close) + settings.update(settings_dict) + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinition) + builder = Builder(self.manifestDefinition, context=sdk_context) archive = io.BytesIO(bytearray()) builder.to_archive(archive) - builder = Builder.from_archive(archive) + builder = Builder({}, context=sdk_context).with_archive(archive) output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() # Get validation state with trust config @@ -2585,7 +2608,7 @@ def test_archive_sign_with_added_ingredient(self): output.close() def test_archive_sign_with_added_ingredient_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings + # Exercise explicit settings on a separate thread result = {} exception = {} @@ -2594,23 +2617,25 @@ def sign_and_validate_with_trust_config(): # Load trust configuration settings_dict = load_test_settings_json() - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) + # Pass the trust configuration explicitly to each operation. + settings = Settings() + self.addCleanup(settings.close) + settings.update(settings_dict) + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) + builder = Builder(self.manifestDefinitionV2, context=sdk_context) archive = io.BytesIO(bytearray()) builder.to_archive(archive) - builder = Builder.from_archive(archive) + builder = Builder({}, context=sdk_context).with_archive(archive) output = io.BytesIO(bytearray()) ingredient_json = '{"test": "ingredient"}' with open(self.testPath, 'rb') as f: builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() # Get validation state with trust config @@ -2972,6 +2997,10 @@ def test_ingredient_from_archive_linked_to_placed_action(self): "label": "c2pa.actions.v2", "data": { "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }, { "action": "c2pa.placed", "parameters": {"ingredientIds": [ingredient_id]}, @@ -3016,6 +3045,10 @@ def test_ingredient_from_archive_linked_to_edited_action(self): "label": "c2pa.actions.v2", "data": { "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }, { "action": "c2pa.edited", "parameters": {"ingredientIds": [ingredient_id]}, @@ -3324,7 +3357,7 @@ def test_remote_sign_using_returned_bytes_V2(self): self.assertIn("Python Test", manifest_data) def test_remote_sign_using_returned_bytes_V2_with_trust_config(self): - # Run in a separate thread to isolate thread-local settings + # Exercise explicit settings on a separate thread result = {} exception = {} @@ -3333,13 +3366,15 @@ def sign_and_validate_with_trust_config(): # Load trust configuration settings_dict = load_test_settings_json() - # Apply the settings (including trust configuration) - # Settings are thread-local, so they won't affect other tests - # And that is why we also run the test in its own thread, so tests are isolated - load_settings(settings_dict) + # Pass the trust configuration explicitly to each operation. + settings = Settings() + self.addCleanup(settings.close) + settings.update(settings_dict) + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) with open(self.testPath, "rb") as file: - builder = Builder(self.manifestDefinitionV2) + builder = Builder(self.manifestDefinitionV2, context=sdk_context) builder.set_no_embed() with io.BytesIO() as output_buffer: manifest_data = builder.sign( @@ -3347,7 +3382,7 @@ def sign_and_validate_with_trust_config(): output_buffer.seek(0) read_buffer = io.BytesIO(output_buffer.getvalue()) - with Reader("image/jpeg", read_buffer, manifest_data) as reader: + with Reader("image/jpeg", read_buffer, manifest_data, context=sdk_context) as reader: json_data = reader.json() # Get validation state with trust config @@ -3745,8 +3780,10 @@ def test_builder_sign_with_ingredients_edit_intent(self): self.assertIsInstance(ingredients_manifest, list) self.assertEqual(len(ingredients_manifest), 2, "Should have exactly two ingredients") - # Verify the first ingredient is the one we added manually with componentOf relationship - first_ingredient = ingredients_manifest[0] + # Ingredient order is not part of the SDK contract. + first_ingredient = next( + ing for ing in ingredients_manifest + if ing["relationship"] == "componentOf") self.assertEqual(first_ingredient["title"], "Test Ingredient") self.assertEqual(first_ingredient["format"], "image/jpeg") self.assertIn("instance_id", first_ingredient) @@ -3756,8 +3793,10 @@ def test_builder_sign_with_ingredients_edit_intent(self): self.assertEqual(first_ingredient["relationship"], "componentOf") self.assertIn("label", first_ingredient) - # Verify the second ingredient is the auto-created parent with parentOf relationship - second_ingredient = ingredients_manifest[1] + # Verify the auto-created parent. + second_ingredient = next( + ing for ing in ingredients_manifest + if ing["relationship"] == "parentOf") # Parent ingredient may not have a title field, or may have an empty one self.assertEqual(second_ingredient["format"], "image/jpeg") self.assertIn("instance_id", second_ingredient) @@ -3813,9 +3852,13 @@ def test_builder_sign_with_ingredients_edit_intent(self): def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): # The following removes the manifest's thumbnail # Settings should be loaded before the builder is created - load_settings('{"builder": { "thumbnail": {"enabled": false}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder": { "thumbnail": {"enabled": false}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) - builder = Builder.from_json(self.manifestDefinition) + builder = Builder.from_json(self.manifestDefinition, context=sdk_context) assert builder._handle is not None # Test adding ingredient @@ -3827,7 +3870,7 @@ def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() manifest_data = json.loads(json_data) @@ -3855,14 +3898,16 @@ def test_builder_sign_with_setting_no_thumbnail_and_ingredient(self): builder.close() - # Settings are thread-local, so we reset to the default "true" here - load_settings('{"builder": { "thumbnail": {"enabled": true}}}') def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): # The following removes the manifest's thumbnail - using dict instead of string - load_settings({"builder": {"thumbnail": {"enabled": False}}}) + settings = Settings() + self.addCleanup(settings.close) + settings.update({"builder": {"thumbnail": {"enabled": False}}}) + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) - builder = Builder.from_json(self.manifestDefinition) + builder = Builder.from_json(self.manifestDefinition, context=sdk_context) assert builder._handle is not None # Test adding ingredient @@ -3874,7 +3919,7 @@ def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() manifest_data = json.loads(json_data) @@ -3902,8 +3947,6 @@ def test_builder_sign_with_settingdict_no_thumbnail_and_ingredient(self): builder.close() - # Settings are thread-local, so we reset to the default "true" here - using dict instead of string - load_settings({"builder": {"thumbnail": {"enabled": True}}}) def test_builder_sign_with_duplicate_ingredient(self): builder = Builder.from_json(self.manifestDefinition) @@ -4139,9 +4182,13 @@ def test_builder_set_remote_url_no_embed(self): """Test setting the remote url of a builder with no embed flag.""" # Settings need to be loaded before the builder is created - load_settings(r'{"verify": { "remote_manifest_fetch": false} }') + settings = Settings() + self.addCleanup(settings.close) + settings.update(r'{"verify": { "remote_manifest_fetch": false} }') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) - builder = Builder.from_json(self.manifestDefinition) + builder = Builder.from_json(self.manifestDefinition, context=sdk_context) builder.set_no_embed() builder.set_remote_url("http://this_does_not_exist/foo.jpg") @@ -4150,12 +4197,10 @@ def test_builder_set_remote_url_no_embed(self): builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) with self.assertRaises(Error) as e: - Reader("image/jpeg", output) + Reader("image/jpeg", output, context=sdk_context) self.assertIn("http://this_does_not_exist/foo.jpg", e.exception.message) - # Return back to default settings - load_settings(r'{"verify": { "remote_manifest_fetch": true} }') def test_sign_single(self): """Test signing a file using the sign_file method.""" @@ -4281,10 +4326,22 @@ def test_sign_file_format_manifest_bytes_embeddable(self): builder = Builder(self.manifestDefinition) output = io.BytesIO(bytearray()) - with open(self.testPath, "rb") as file: + with open(self.testPath2, "rb") as file: manifest_bytes = builder.sign(self.signer, "image/jpeg", file, output) - res = format_embeddable("image/jpeg", manifest_bytes) - output.seek(0) + size, embedded = format_embeddable("image/jpeg", manifest_bytes) + self.assertEqual(size, len(embedded)) + self.assertTrue(embedded.startswith(b"\xff\xeb")) + with Builder({}) as composer: + self.assertEqual( + composer.compose_manifest("image/jpeg", manifest_bytes), + (size, embedded)) + file.seek(0) + source_bytes = file.read() + composed = source_bytes[:2] + embedded + source_bytes[2:] + with Reader("image/jpeg", io.BytesIO(composed)) as reader: + self.assertEqual( + reader.get_active_manifest()["title"], "Python Test Image") + self.assertEqual(reader.get_validation_state(), "Valid") output.close() def test_builder_sign_file_callback_signer_from_callback(self): @@ -4868,7 +4925,11 @@ def test_builder_state_with_invalid_native_pointer(self): def test_builder_add_action_to_manifest_no_auto_add(self): # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) initial_manifest_definition = { "claim_generator_info": [{ @@ -4893,7 +4954,7 @@ def test_builder_add_action_to_manifest_no_auto_add(self): } ] } - builder = Builder.from_json(initial_manifest_definition) + builder = Builder.from_json(initial_manifest_definition, context=sdk_context) action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' builder.add_action(action_json) @@ -4902,7 +4963,7 @@ def test_builder_add_action_to_manifest_no_auto_add(self): output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() manifest_data = json.loads(json_data) @@ -4943,12 +5004,14 @@ def test_builder_add_action_to_manifest_no_auto_add(self): builder.close() - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') def test_builder_add_action_to_manifest_from_dict_no_auto_add(self): # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) initial_manifest_definition = { "claim_generator_info": [{ @@ -4973,7 +5036,7 @@ def test_builder_add_action_to_manifest_from_dict_no_auto_add(self): } ] } - builder = Builder.from_json(initial_manifest_definition) + builder = Builder.from_json(initial_manifest_definition, context=sdk_context) # Using a dictionary instead of a JSON string action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} @@ -4983,7 +5046,7 @@ def test_builder_add_action_to_manifest_from_dict_no_auto_add(self): output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() manifest_data = json.loads(json_data) @@ -5024,12 +5087,14 @@ def test_builder_add_action_to_manifest_from_dict_no_auto_add(self): builder.close() - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') def test_builder_add_action_to_manifest_with_auto_add(self): # For testing, force settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) initial_manifest_definition = { "claim_generator_info": [{ @@ -5055,7 +5120,7 @@ def test_builder_add_action_to_manifest_with_auto_add(self): } ] } - builder = Builder.from_json(initial_manifest_definition) + builder = Builder.from_json(initial_manifest_definition, context=sdk_context) action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' builder.add_action(action_json) @@ -5064,7 +5129,7 @@ def test_builder_add_action_to_manifest_with_auto_add(self): output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() manifest_data = json.loads(json_data) @@ -5113,12 +5178,14 @@ def test_builder_add_action_to_manifest_with_auto_add(self): builder.close() - # Reset settings to default - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') def test_builder_minimal_manifest_add_actions_and_sign_no_auto_add(self): # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) initial_manifest_definition = { "claim_generator": "python_test", @@ -5130,14 +5197,14 @@ def test_builder_minimal_manifest_add_actions_and_sign_no_auto_add(self): "title": "Python Test Image V2", } - builder = Builder.from_json(initial_manifest_definition) + builder = Builder.from_json(initial_manifest_definition, context=sdk_context) builder.add_action('{ "action": "c2pa.created", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}') 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) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() manifest_data = json.loads(json_data) @@ -5178,12 +5245,14 @@ def test_builder_minimal_manifest_add_actions_and_sign_no_auto_add(self): builder.close() - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): - # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + # Enable auto-added actions. + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) initial_manifest_definition = { "claim_generator_info": [{ @@ -5194,7 +5263,7 @@ def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): "title": "Python Test Image V2", } - builder = Builder.from_json(initial_manifest_definition) + builder = Builder.from_json(initial_manifest_definition, context=sdk_context) action_json = '{"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}}' builder.add_action(action_json) @@ -5202,7 +5271,7 @@ def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() manifest_data = json.loads(json_data) @@ -5219,19 +5288,13 @@ def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): self.assertIn("assertions", active_manifest) assertions = active_manifest["assertions"] - # Find the c2pa.actions.v2 assertion to look for what we added - actions_assertion = None - for assertion in assertions: - if assertion.get("label") == "c2pa.actions.v2": - actions_assertion = assertion - break - - self.assertIsNotNone(actions_assertion) - self.assertIn("data", actions_assertion) - assertion_data = actions_assertion["data"] - # Verify the manifest now contains actions - self.assertIn("actions", assertion_data) - actions = assertion_data["actions"] + # 0.92 may separate created and gathered actions assertions. + actions = [ + action for assertion in assertions + if assertion.get("label") == "c2pa.actions.v2" + for action in assertion["data"]["actions"] + ] + self.assertTrue(actions) # Verify "c2pa.created" action exists anywhere in the actions array created_action_found = False for action in actions: @@ -5241,7 +5304,7 @@ def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): self.assertTrue(created_action_found) - # Verify "c2pa.color_adjustments" action also exists in the same actions array + # Verify the caller's action is retained as well. color_adjustments_found = False for action in actions: if action.get("action") == "c2pa.color_adjustments": @@ -5252,12 +5315,14 @@ def test_builder_minimal_manifest_add_actions_and_sign_with_auto_add(self): builder.close() - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') def test_builder_sign_dicts_no_auto_add(self): # For testing, remove auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) initial_manifest_definition = { "claim_generator_info": [{ @@ -5282,7 +5347,7 @@ def test_builder_sign_dicts_no_auto_add(self): } ] } - builder = Builder.from_json(initial_manifest_definition) + builder = Builder.from_json(initial_manifest_definition, context=sdk_context) # Using a dictionary instead of a JSON string action_dict = {"action": "c2pa.color_adjustments", "parameters": {"name": "brightnesscontrast"}} @@ -5292,7 +5357,7 @@ def test_builder_sign_dicts_no_auto_add(self): output = io.BytesIO(bytearray()) builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) json_data = reader.json() manifest_data = json.loads(json_data) @@ -5333,13 +5398,15 @@ def test_builder_sign_dicts_no_auto_add(self): builder.close() - # Reset settings - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":true},"auto_opened_action":{"enabled":true},"auto_created_action":{"enabled":true,"source_type":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}}}}') def test_builder_opened_action_one_ingredient_no_auto_add(self): """Test Builder with c2pa.opened action and one ingredient, following Adobe provenance patterns""" # Disable auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) # Instance IDs for linking ingredients and actions # This can be any unique id so the ingredient can be uniquely identified and linked to the action @@ -5390,7 +5457,7 @@ def test_builder_opened_action_one_ingredient_no_auto_add(self): with open(self.testPath2, "rb") as test_file: file_content = test_file.read() - builder = Builder.from_json(manifestDefinition) + builder = Builder.from_json(manifestDefinition, context=sdk_context) # Add C.jpg as the parent "opened" ingredient with open(self.testPath, 'rb') as f: @@ -5405,7 +5472,7 @@ def test_builder_opened_action_one_ingredient_no_auto_add(self): output_buffer.seek(0) # Read and verify the manifest - reader = Reader("image/jpeg", output_buffer) + reader = Reader("image/jpeg", output_buffer, context=sdk_context) json_data = reader.json() manifest_data = json.loads(json_data) @@ -5417,13 +5484,15 @@ def test_builder_opened_action_one_ingredient_no_auto_add(self): builder.close() - # Make sure settings are put back to the common test defaults - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') def test_builder_one_opened_one_placed_action_no_auto_add(self): """Test Builder with c2pa.opened action where asset is its own parent ingredient""" # Disable auto-added actions - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) # Instance IDs for linking ingredients and actions, # need to be unique even if the same binary file is used, so ingredients link properly to actions @@ -5493,7 +5562,7 @@ def test_builder_one_opened_one_placed_action_no_auto_add(self): with open(self.testPath2, "rb") as test_file: file_content = test_file.read() - builder = Builder.from_json(manifestDefinition) + builder = Builder.from_json(manifestDefinition, context=sdk_context) # An asset can be its own parent ingredient! # We add A.jpg as its own parent ingredient @@ -5509,7 +5578,7 @@ def test_builder_one_opened_one_placed_action_no_auto_add(self): output_buffer.seek(0) # Read and verify the manifest - reader = Reader("image/jpeg", output_buffer) + reader = Reader("image/jpeg", output_buffer, context=sdk_context) json_data = reader.json() manifest_data = json.loads(json_data) @@ -5523,13 +5592,15 @@ def test_builder_one_opened_one_placed_action_no_auto_add(self): builder.close() - # Make sure settings are put back to the common test defaults - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') def test_builder_opened_action_multiple_ingredient_no_auto_add(self): """Test Builder with c2pa.opened and c2pa.placed actions with multiple ingredients""" # Disable auto-added actions, as what we are doing here can confuse auto-placements - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) # Instance IDs for linking ingredients and actions # With multiple ingredients, we need multiple different unique ids so they each link properly @@ -5606,7 +5677,7 @@ def test_builder_opened_action_multiple_ingredient_no_auto_add(self): with open(self.testPath2, "rb") as test_file: file_content = test_file.read() - builder = Builder.from_json(manifestDefinition) + builder = Builder.from_json(manifestDefinition, context=sdk_context) # Add C.jpg as the parent ingredient (for c2pa.opened, it's the opened asset) with open(self.testPath, 'rb') as f1: @@ -5625,7 +5696,7 @@ def test_builder_opened_action_multiple_ingredient_no_auto_add(self): output_buffer.seek(0) # Read and verify the manifest - reader = Reader("image/jpeg", output_buffer) + reader = Reader("image/jpeg", output_buffer, context=sdk_context) json_data = reader.json() manifest_data = json.loads(json_data) @@ -5640,13 +5711,15 @@ def test_builder_opened_action_multiple_ingredient_no_auto_add(self): builder.close() - # Make sure settings are put back to the common test defaults - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') def test_link_archive_label_on_signing_builder_placed(self): """Label set on the signing builder's add_ingredient links an ingredient archive to a c2pa.placed action.""" - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) archive = self._create_ingredient_archive() @@ -5673,7 +5746,7 @@ def test_link_archive_label_on_signing_builder_placed(self): ], } - builder = Builder.from_json(manifest) + builder = Builder.from_json(manifest, context=sdk_context) builder.add_ingredient( {"title": "photo.jpg", "relationship": "componentOf", "label": "my-ingredient"}, "application/c2pa", @@ -5685,7 +5758,7 @@ def test_link_archive_label_on_signing_builder_placed(self): builder.sign(self.signer, "image/jpeg", src, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) manifest_data = json.loads(reader.json()) active = manifest_data["active_manifest"] assertions = manifest_data["manifests"][active]["assertions"] @@ -5712,12 +5785,15 @@ def test_link_archive_label_on_signing_builder_placed(self): archive.close() builder.close() - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') def test_link_archive_label_on_signing_builder_opened(self): """Label set on the signing builder's add_ingredient links an ingredient archive to a c2pa.opened action.""" - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) archive = self._create_ingredient_archive( {"title": "photo.jpg", "relationship": "parentOf"} @@ -5743,7 +5819,7 @@ def test_link_archive_label_on_signing_builder_opened(self): ], } - builder = Builder.from_json(manifest) + builder = Builder.from_json(manifest, context=sdk_context) builder.add_ingredient( {"title": "photo.jpg", "relationship": "parentOf", "label": "my-ingredient"}, "application/c2pa", @@ -5755,7 +5831,7 @@ def test_link_archive_label_on_signing_builder_opened(self): builder.sign(self.signer, "image/jpeg", src, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) manifest_data = json.loads(reader.json()) active = manifest_data["active_manifest"] assertions = manifest_data["manifests"][active]["assertions"] @@ -5782,12 +5858,15 @@ def test_link_archive_label_on_signing_builder_opened(self): archive.close() builder.close() - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') def test_link_archive_two_ingredients_labels(self): """Two ingredient archives linked to two different actions via distinct labels. Verifies no cross-linking.""" - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) archive1 = self._create_ingredient_archive( {"title": "photo-placed.jpg", "relationship": "componentOf"} @@ -5822,7 +5901,7 @@ def test_link_archive_two_ingredients_labels(self): ], } - builder = Builder.from_json(manifest) + builder = Builder.from_json(manifest, context=sdk_context) builder.add_ingredient( {"title": "photo-placed.jpg", "relationship": "componentOf", "label": "ingredient-for-placed"}, "application/c2pa", @@ -5839,7 +5918,7 @@ def test_link_archive_two_ingredients_labels(self): builder.sign(self.signer, "image/jpeg", src, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) manifest_data = json.loads(reader.json()) active = manifest_data["active_manifest"] assertions = manifest_data["manifests"][active]["assertions"] @@ -5875,12 +5954,15 @@ def test_link_archive_two_ingredients_labels(self): archive2.close() builder.close() - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') def test_link_archive_multiple_ingredients_in_one_placed_action(self): """A single c2pa.placed action references two componentOf ingredients via ingredientIds with two labels.""" - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + settings = Settings() + self.addCleanup(settings.close) + settings.update('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') + sdk_context = Context(settings=settings) + self.addCleanup(sdk_context.close) archive1 = self._create_ingredient_archive( {"title": "base-layer.jpg", "relationship": "componentOf"} @@ -5912,7 +5994,7 @@ def test_link_archive_multiple_ingredients_in_one_placed_action(self): ], } - builder = Builder.from_json(manifest) + builder = Builder.from_json(manifest, context=sdk_context) builder.add_ingredient( {"title": "base-layer.jpg", "relationship": "componentOf", "label": "base-layer"}, "application/c2pa", @@ -5929,7 +6011,7 @@ def test_link_archive_multiple_ingredients_in_one_placed_action(self): builder.sign(self.signer, "image/jpeg", src, output) output.seek(0) - reader = Reader("image/jpeg", output) + reader = Reader("image/jpeg", output, context=sdk_context) manifest_data = json.loads(reader.json()) active = manifest_data["active_manifest"] assertions = manifest_data["manifests"][active]["assertions"] @@ -5960,7 +6042,6 @@ def test_link_archive_multiple_ingredients_in_one_placed_action(self): archive2.close() builder.close() - load_settings('{"builder":{"actions":{"auto_placed_action":{"enabled":false},"auto_opened_action":{"enabled":false},"auto_created_action":{"enabled":false}}}}') def test_ingredient_fields_survive_archive(self): archive = self._create_ingredient_archive({ @@ -6232,7 +6313,6 @@ def setUp(self): # Filter specific deprecation warnings for legacy API tests 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") self.data_dir = FIXTURES_DIR self.testPath = DEFAULT_TEST_FILE @@ -8376,13 +8456,13 @@ def test_construction_failure_leaves_nothing_to_free(self): finally: c2pa_module._lib.c2pa_context_new = real_new - real_json = c2pa_module._lib.c2pa_builder_from_json - c2pa_module._lib.c2pa_builder_from_json = lambda j: None + real_json = c2pa_module._lib.c2pa_builder_from_context + c2pa_module._lib.c2pa_builder_from_context = lambda ctx: None try: with self.assertRaises(Error): Builder({"claim_generator": "test"}) finally: - c2pa_module._lib.c2pa_builder_from_json = real_json + c2pa_module._lib.c2pa_builder_from_context = real_json def test_context_build_null_return_frees_builder(self): # Mock a pointer rejection. @@ -8991,14 +9071,52 @@ def test_check_cstr_arg_rejects_none_and_embedded_nul(self): c2pa_module._check_cstr_arg('format', b"") def test_format_embeddable_null_out_pointer_raises_not_crashes(self): - real = c2pa_module._lib.c2pa_format_embeddable - c2pa_module._lib.c2pa_format_embeddable = ( - lambda fmt, data, size, out: 128) + real = c2pa_module._lib.c2pa_builder_compose_manifest + c2pa_module._lib.c2pa_builder_compose_manifest = ( + lambda builder, fmt, data, size, out: 128) try: with self.assertRaises(Error): format_embeddable("image/jpeg", b"junk") finally: - c2pa_module._lib.c2pa_format_embeddable = real + c2pa_module._lib.c2pa_builder_compose_manifest = real + + def test_compose_manifest_requires_a_live_builder(self): + builder = Builder({}) + builder.close() + with self.assertRaisesRegex(Error, "closed"), patch.object( + c2pa_module._lib, 'c2pa_builder_compose_manifest', + ) as compose: + builder.compose_manifest("image/jpeg", b"manifest") + compose.assert_not_called() + + def test_compose_manifest_rejects_invalid_inputs_before_native_call(self): + with Builder({}) as builder, patch.object( + c2pa_module._lib, 'c2pa_builder_compose_manifest', + ) as compose: + for format, data in [ + ("image/\x00jpeg", b"manifest"), + ("image/jpeg", b""), + ("image/jpeg", None), + ]: + with self.subTest(format=format, data=data): + with self.assertRaises(Error): + builder.compose_manifest(format, data) + compose.assert_not_called() + + def test_compose_manifest_keeps_builder_and_propagates_native_error(self): + def reject_composition(*args): + c2pa_module._lib.c2pa_error_set_last( + b"NotSupported: composition not supported") + return -1 + + with Builder({}) as builder, patch.object( + c2pa_module._lib, 'c2pa_builder_compose_manifest', + side_effect=reject_composition, + ): + with self.assertRaisesRegex(Error.NotSupported, + "composition not supported"): + builder.compose_manifest("image/jpeg", b"manifest") + self.assertTrue(builder.is_valid) def test_check_bytes_arg_rejects_none_and_empty(self): for bad in (None, b""): @@ -9345,7 +9463,11 @@ def _raw_builder_handle(self): manifest = json.dumps( {"claim_generator": "raw_ffi_test", "format": "image/jpeg"} ).encode("utf-8") - handle = c2pa_module._lib.c2pa_builder_from_json(manifest) + with Context() as context: + handle = c2pa_module._lib.c2pa_builder_from_context( + context.execution_context) + self.assertTrue(handle, "the FFI did not return a builder pointer") + handle = c2pa_module._lib.c2pa_builder_with_definition(handle, manifest) self.assertTrue(handle, "the FFI did not return a builder pointer") return handle @@ -9601,8 +9723,10 @@ def _boom(*args, **kwargs): finally: Builder._wrap_native_handle = real_wrap - self.assertEqual(len(freed), 1, - "from_archive leaked the handle when the wrap failed") + self.assertEqual(len(freed), 2, + "from_archive must free the default context and builder") + self.assertEqual(len({self._ptr_addr(ptr) for ptr in freed}), 2, + "a native handle was freed twice") def test_sign_failure_chains_the_original_exception(self): # The wrapper re-raises as C2paError; losing __cause__ hides which diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py index 2722ea59..9a2bb50a 100644 --- a/tests/test_unit_tests_threaded.py +++ b/tests/test_unit_tests_threaded.py @@ -3587,13 +3587,13 @@ def gated_teardown(self, free_handle, _target=reader, self.assertEqual(len(release_calls), 1, "_release() not idempotent") def test_sign_raising_native_call_closes_builder(self): - freed = self._counted_free() signer = Signer.from_info(C2paSignerInfo( alg=b"es256", sign_cert=self.certs, private_key=self.private_key, ta_url=None)) self.addCleanup(signer.close) builder = Builder({"claim_generator": "python_test", "format": "image/jpeg", "assertions": []}) + freed = self._counted_free() real_sign = c2pa_module._lib.c2pa_builder_sign def raising(*args):