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/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 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):