Skip to content

Commit 2cecf27

Browse files
committed
ci: Merge remote-tracking branch 'refs/remotes/origin/mathern/context' into mathern/context
2 parents 2f8f96a + 5a04949 commit 2cecf27

5 files changed

Lines changed: 241 additions & 31 deletions

File tree

c2pa-native-version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
c2pa-v0.77.0
1+
c2pa-v0.77.1

docs/native-resources-management.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,13 @@ classDiagram
5454
ManagedResource <|-- Signer
5555
5656
ContextProvider <|-- Context
57-
ContextProvider <|-- Settings
5857
```
5958

60-
`Context` and `Settings` inherit from both `ManagedResource` and `ContextProvider` (Python supports multiple inheritance). `ContextProvider` is an ABC that requires two properties: `is_valid` and `execution_context`. The `is_valid` implementation lives on `ManagedResource`, so `Context` and `Settings` satisfy the `ContextProvider` contract without duplicating the property.
59+
`Context` inherits from both `ManagedResource` and `ContextProvider` (Python supports multiple inheritance). `Settings` inherits from `ManagedResource` only. `ContextProvider` is an ABC that requires two properties: `is_valid` and `execution_context`. The `is_valid` implementation lives on `ManagedResource`, so `Context` satisfies that part of the `ContextProvider` contract without duplicating the property.
6160

6261
## Guarantees provided by ManagedResource
6362

64-
`ManagedResource` provides the following guarantees. Subclasses and callers can rely on them. These guarantees invariants must be maintained when subclassing the `ManagedResource` class in new implementation/new native resources handlers.
63+
`ManagedResource` provides the following guarantees. Subclasses and callers can rely on them. These invariants must be maintained when subclassing the `ManagedResource` class in new implementation/new native resources handlers.
6564

6665
| Guarantee | Description |
6766
| --------- | ----------- |
@@ -280,7 +279,7 @@ The reason is that ownership runs in the opposite direction. A `Reader` or `Buil
280279
To wrap a new native resource, inherit from `ManagedResource` and follow these rules:
281280

282281
```python
283-
class MyResource(ManagedResource):
282+
class NativeResource(ManagedResource):
284283
def __init__(self, arg):
285284
super().__init__()
286285

@@ -304,8 +303,14 @@ class MyResource(ManagedResource):
304303

305304
def _release(self):
306305
# 4. Clean up class-specific resources.
307-
# Never let this method raise. Use try/except with
308-
# logging if needed.
306+
# Never let this method raise. Must be idempotent.
307+
#
308+
# Consider defining a simple lifecycle for native resources
309+
# so _release() can check whether they are releasable
310+
# before attempting cleanup. The if-guard below
311+
# verifies the stream exists and has not
312+
# already been released. The try/except is a fallback
313+
# that silences unexpected errors from .close().
309314
if self._my_stream:
310315
try:
311316
self._my_stream.close()
@@ -327,7 +332,7 @@ class MyResource(ManagedResource):
327332

328333
- If `_lifecycle_state = ACTIVE` is set before the FFI call and the call fails, cleanup will try to free a null or invalid pointer. Activation should happen only after a valid handle exists.
329334

330-
- If `_release()` raises, the exception is silently swallowed by `_cleanup_resources()`. It will not be visible unless logs are checked. Wrap risky operations in try/except.
335+
- If `_release()` raises, the exception is silently swallowed by `_cleanup_resources()`. It will not be visible unless logs are checked. Define a lifecycle for managed resources so `_release()` can check whether they need releasing. Wrap the actual release call in try/except as a fallback for unexpected failures.
331336

332337
- `_release()` can be called more than once (via `close()` then `__del__`, or multiple `close()` calls). Make sure it handles being called on an already-cleaned-up object. Setting attributes to `None` after closing them is the standard pattern.
333338

docs/usage.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,27 @@ ctx = Context.builder().with_settings(settings).build()
318318
ctx = Context.builder().build()
319319
```
320320

321+
You can call `with_settings()` multiple times. This is useful when different code paths each need to configure settings before the context is built. Each call replaces the previous `Settings` object entirely (the last one wins):
322+
323+
```py
324+
# Only settings_b is used, settings_a is replaced
325+
ctx = (
326+
Context.builder()
327+
.with_settings(settings_a)
328+
.with_settings(settings_b)
329+
.build()
330+
)
331+
```
332+
333+
To merge multiple configurations into one, use `Settings.update()` on a single `Settings` object, and then pass the built Settings object to the context:
334+
335+
```py
336+
settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}})
337+
settings.update({"verify": {"remote_manifest_fetch": True}})
338+
339+
ctx = Context.builder().with_settings(settings).build()
340+
```
341+
321342
### Context with a Signer
322343

323344
When a `Signer` is passed to `Context`, the `Signer` object is consumed and must not be reused directly. The `Context` takes ownership of the underlying native signer. This allows signing without passing an explicit signer to `Builder.sign()`.
@@ -353,16 +374,47 @@ manifest_bytes = builder.sign(explicit_signer, "image/jpeg", source, dest)
353374

354375
### ContextProvider (abstract base class)
355376

356-
`ContextProvider` is an abstract base class (ABC) that allows third-party implementations of custom context providers. Any class that implements the `is_valid` and `execution_context` properties satisfies the interface and can be passed to `Reader` or `Builder` as `context`.
377+
`ContextProvider` is an abstract base class (ABC) that defines the interface `Reader` and `Builder` use to access a context. It requires two properties:
378+
379+
- `is_valid` (bool): Whether the provider is in a usable state. `Reader` and `Builder` check this before every operation.
380+
- `execution_context`: The raw native context pointer (`C2paContext` handle). `Reader` and `Builder` pass this to the native library for FFI calls.
381+
382+
The built-in `Context` class is the standard implementation to provide context:
357383

358384
```py
359385
from c2pa import ContextProvider, Context
360386

361-
# The built-in Context satisfies ContextProvider
362387
ctx = Context()
363388
assert isinstance(ctx, ContextProvider)
364389
```
365390

391+
Any class can become a `ContextProvider` by inheriting from `ContextProvider` and implementing both properties. The two properties can live on any object through multiple inheritance, but a dedicated context class (as done in the SDK with `Context`) is preferred because it handles native memory management, lifecycle states, and signer ownership.
392+
393+
In practice, `execution_context` must return a pointer that the native C2PA library understands, so custom providers will likely wrap a compatible native resource, rather than constructing native pointers independently:
394+
395+
```py
396+
from c2pa import ContextProvider, Context, Settings
397+
398+
class MyContextProvider(ContextProvider):
399+
"""Custom provider that wraps a Context with application-specific logic."""
400+
401+
def __init__(self, config: dict):
402+
self._ctx = Context(settings=Settings.from_dict(config))
403+
404+
@property
405+
def is_valid(self) -> bool:
406+
return self._ctx.is_valid
407+
408+
@property
409+
def execution_context(self):
410+
return self._ctx.execution_context
411+
412+
def close(self):
413+
self._ctx.close()
414+
```
415+
416+
`Settings` is not a `ContextProvider`. It inherits from `ManagedResource` only and cannot be passed directly as the `context` parameter to `Reader` or `Builder`.
417+
366418
### Migrating from load_settings
367419

368420
The `load_settings()` function that set settings in a thread-local fashion is deprecated.

src/c2pa/c2pa.py

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
'c2pa_reader_from_context',
5353
'c2pa_reader_with_stream',
5454
'c2pa_reader_with_fragment',
55+
'c2pa_reader_with_manifest_data_and_stream',
5556
'c2pa_reader_is_embedded',
5657
'c2pa_reader_remote_url',
5758
'c2pa_reader_supported_mime_types',
@@ -102,7 +103,7 @@ def _validate_library_exports(lib):
102103
This validation is crucial for several security and reliability reasons:
103104
104105
1. Security:
105-
- Prevents loading of lmibraries that might be missing critical functions
106+
- Prevents loading of libraries that might be missing critical functions
106107
- Ensures the library has expected functionality before code execution
107108
- Helps detect tampered or incomplete libraries
108109
@@ -708,6 +709,13 @@ def _setup_function(func, argtypes, restype=None):
708709
ctypes.POINTER(C2paStream)],
709710
ctypes.POINTER(C2paReader)
710711
)
712+
_setup_function(
713+
_lib.c2pa_reader_with_manifest_data_and_stream,
714+
[ctypes.POINTER(C2paReader), ctypes.c_char_p,
715+
ctypes.POINTER(C2paStream),
716+
ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t],
717+
ctypes.POINTER(C2paReader),
718+
)
711719
_setup_function(
712720
_lib.c2pa_reader_with_fragment,
713721
[ctypes.POINTER(C2paReader), ctypes.c_char_p,
@@ -1028,30 +1036,34 @@ def _check_ffi_operation_result(result, fallback_msg, *, check=lambda r: not r):
10281036
return result
10291037

10301038

1031-
def _to_utf8_bytes(data, error_context="input") -> bytes:
1039+
def _to_utf8_bytes(data: Union[str, dict], error_context: str = "input") -> bytes:
10321040
"""Convert a string or dict to UTF-8 bytes.
10331041
10341042
If data is a dict, it is serialized to JSON first.
10351043
10361044
Args:
1037-
data: String or dict to encode
1038-
error_context: Description for error messages
1045+
data: String or dict to encode.
1046+
error_context: Description for error messages.
10391047
10401048
Returns:
1041-
UTF-8 encoded bytes
1049+
UTF-8 encoded bytes.
10421050
10431051
Raises:
1044-
C2paError.Json: If dict serialization fails
1045-
C2paError.Encoding: If UTF-8 encoding fails
1052+
C2paError.Json: If dict serialization fails.
1053+
C2paError.Encoding: If UTF-8 encoding fails or data is not a supported type.
10461054
"""
10471055
if isinstance(data, dict):
10481056
try:
10491057
data = json.dumps(data)
10501058
except (TypeError, ValueError) as e:
10511059
raise C2paError.Json(f"Failed to serialize {error_context}: {e}")
1060+
if not isinstance(data, str):
1061+
raise C2paError.Encoding(
1062+
f"Expected str or dict for {error_context}, got {type(data).__name__}"
1063+
)
10521064
try:
10531065
return data.encode('utf-8')
1054-
except (UnicodeError, AttributeError) as e:
1066+
except UnicodeError as e:
10551067
raise C2paError.Encoding(f"Invalid UTF-8 in {error_context}: {e}")
10561068

10571069

@@ -1375,11 +1387,29 @@ class ContextProvider(ABC):
13751387

13761388
@property
13771389
@abstractmethod
1378-
def is_valid(self) -> bool: ...
1390+
def is_valid(self) -> bool:
1391+
"""Whether this provider is in a usable state.
1392+
1393+
Return True when the underlying native context is active
1394+
and its handle has not been freed or consumed. Return
1395+
False after the provider has been closed or invalidated.
1396+
1397+
The ManagedResource base class provides a standard
1398+
implementation that checks lifecycle state and handle
1399+
presence.
1400+
"""
1401+
...
13791402

13801403
@property
13811404
@abstractmethod
1382-
def execution_context(self): ...
1405+
def execution_context(self):
1406+
"""Return the raw native C2paContext pointer.
1407+
1408+
The returned pointer must be valid for the duration of any
1409+
FFI call that uses it. Callers should check is_valid before
1410+
accessing this property.
1411+
"""
1412+
...
13831413

13841414

13851415
class Settings(ManagedResource):
@@ -1453,6 +1483,8 @@ def update(
14531483
self, data: Union[str, dict],
14541484
) -> 'Settings':
14551485
"""Update current configuration from a JSON string or dict.
1486+
If the updated string overwrite an existing settings value,
1487+
the last setting value set for that property wins.
14561488
14571489
Args:
14581490
data: A JSON string or dict with configuration to merge.
@@ -1493,7 +1525,19 @@ def __init__(self):
14931525
def with_settings(
14941526
self, settings: 'Settings',
14951527
) -> 'ContextBuilder':
1496-
"""Attach Settings to the Context being built."""
1528+
"""Attach Settings to the Context being built.
1529+
1530+
Can be called multiple times, but each call replaces
1531+
the previous Settings object entirely (the last one wins).
1532+
To merge multiple configurations, use Settings.update()
1533+
on a single Settings instance before passing it in.
1534+
1535+
Args:
1536+
settings: The Settings instance to use.
1537+
1538+
Returns:
1539+
self, for method chaining.
1540+
"""
14971541
self._settings = settings
14981542
return self
14991543

@@ -2244,6 +2288,7 @@ def __init__(
22442288
if context is not None:
22452289
self._init_from_context(
22462290
context, format_or_path, stream,
2291+
manifest_data,
22472292
)
22482293
return
22492294

@@ -2334,7 +2379,7 @@ def _init_from_file(self, path, format_bytes,
23342379
Reader._ERROR_MESSAGES['io_error'].format(str(e)))
23352380

23362381
def _init_from_context(self, context, format_or_path,
2337-
stream):
2382+
stream, manifest_data=None):
23382383
"""Initialize Reader from a Context object implementing
23392384
the ContextProvider interface/abstract base class.
23402385
"""
@@ -2365,7 +2410,7 @@ def _init_from_context(self, context, format_or_path,
23652410
self._own_stream = Stream(stream)
23662411

23672412
try:
2368-
# Create base reader from context
2413+
# Create reader from context
23692414
reader_ptr = _lib.c2pa_reader_from_context(
23702415
context.execution_context,
23712416
)
@@ -2375,13 +2420,35 @@ def _init_from_context(self, context, format_or_path,
23752420
].format("Unknown error")
23762421
)
23772422

2378-
# Consume-and-return: reader_ptr is consumed,
2379-
# new_ptr is the valid pointer going forward
2380-
new_ptr = _lib.c2pa_reader_with_stream(
2381-
reader_ptr, format_bytes,
2382-
self._own_stream._stream,
2383-
)
2384-
# reader_ptr has been invalidated(consumed)
2423+
if manifest_data is not None:
2424+
if not isinstance(manifest_data, bytes):
2425+
raise TypeError(
2426+
Reader._ERROR_MESSAGES[
2427+
'manifest_error'])
2428+
manifest_array = (
2429+
ctypes.c_ubyte *
2430+
len(manifest_data))(
2431+
*manifest_data)
2432+
# Consume current reader,
2433+
# with manifest data and stream (C FFI pattern),
2434+
# to create a new one (switch out)
2435+
new_ptr = (
2436+
_lib.c2pa_reader_with_manifest_data_and_stream(
2437+
reader_ptr,
2438+
format_bytes,
2439+
self._own_stream._stream,
2440+
manifest_array,
2441+
len(manifest_data),
2442+
)
2443+
)
2444+
# reader_ptr has been invalidated(consumed)
2445+
else:
2446+
# Consume reader with stream
2447+
new_ptr = _lib.c2pa_reader_with_stream(
2448+
reader_ptr, format_bytes,
2449+
self._own_stream._stream,
2450+
)
2451+
# reader_ptr has been invalidated(consumed)
23852452

23862453
_check_ffi_operation_result(new_ptr,
23872454
Reader._ERROR_MESSAGES[

0 commit comments

Comments
 (0)