Repository navigation
Add structured accessors on Reader for actions, signing info, and ingredients #289
Description
Summary
Every consumer of Reader that needs more than the raw JSON string ends up writing the same small walker to reach three sub-structures of the manifest store: the c2pa.actions action list, signature_info, and ingredients. I'd like to propose three thin, additive accessors on Reader that are get_actions(), get_signing_info(), and get_ingredients(), that replace this boilerplate while staying consistent with the existing get_active_manifest() / get_manifest(label) shape.
Before I open a PR, I want to align on the API shape and a handful of design decisions.
Motivation
Concrete example from a downstream project that uses c2pa-python to detect AI-generated images. Today, reading actions, signing info, and ingredients from a Reader looks like this (~30 lines):
manifest = manifest_store["manifests"][manifest_store["active_manifest"]]
sig_info = manifest.get("signature_info", {})
actions = []
assertion_labels = []
for assertion in manifest.get("assertions", []):
label = assertion.get("label", "")
assertion_labels.append(label)
if label.startswith("c2pa.actions"): # handle both v1 and v2 label variants
for action in assertion.get("data", {}).get("actions", []):
agent = action.get("softwareAgent", {})
agent_name = agent.get("name") if isinstance(agent, dict) else agent
actions.append({"action": action.get("action"), "software_agent": agent_name, ...})
ingredients = manifest.get("ingredients", [])With the proposed accessors, the same three reads become:
sig_info = reader.get_signing_info() or {}
actions = reader.get_actions() or []
ingredients = reader.get_ingredients() or []The walker code is duplicated across every project that consumes Reader. It's also easy to get subtly wrong — for example, missing that c2pa.actions.v2 exists alongside c2pa.actions, or forgetting that data.actions can be absent on some assertions.
Proposed public API
Three new methods on Reader, layered on top of the existing get_active_manifest() / get_manifest(label):
class Reader(ManagedResource):
def get_actions(self, manifest_label: Optional[str] = None) -> Optional[list[dict]]: ...
def get_signing_info(self, manifest_label: Optional[str] = None) -> Optional[dict]: ...
def get_ingredients(self, manifest_label: Optional[str] = None) -> Optional[list[dict]]: ...Behavior contract:
| Scenario | get_actions() |
get_signing_info() |
get_ingredients() |
|---|---|---|---|
| Active manifest present, sub-structure present | list[dict] |
dict |
list[dict] |
| Active manifest present, sub-structure missing | [] |
None |
[] |
| No active manifest (unsigned asset) | None |
None |
None |
manifest_label is a known label |
Result from that manifest | Result from that manifest | Result from that manifest |
manifest_label is an unknown string |
KeyError (from get_manifest) |
KeyError |
KeyError |
| Reader is closed | C2paError (matches Reader.json()) |
C2paError |
C2paError |
Design decisions I'd like feedback on
-
Return
dict/list[dict], not typed dataclasses.
Rationale: consistency withget_active_manifest()andget_manifest(label), resilience to C2PA JSON schema drift (new fields pass through transparently), and no new public types to export from__init__.py. Consumers who want typing can wrap. Open to going the other way if the project has a direction toward typed accessors. -
manifest_label=Nonetargets the active manifest; a string targets a specific manifest.
Rationale: mirrors existing behavior split —get_active_manifest()forNone,get_manifest(label)for specific. Reuses the existing exception semantics (KeyErroron unknown label) rather than inventing a third convention. -
get_actions()handles bothc2pa.actionsandc2pa.actions.v2.
Rationale: the C2PA spec permits both labels on a single manifest. Concatenating in encounter order is the least-surprising default; alternatives (v2-only-if-present, dict-keyed-by-label) either lose information or break the flat-list contract. -
Do not coerce
digitalSourceTypetoC2paDigitalSourceType.
Rationale: the enum can trail the IPTC/CAWG taxonomy, and coercing would mean either silently dropping unknown URIs or raising on them. A separate helper for AI-source-type classification would compose cleanly on top of this without leaking an enum into a passthrough accessor. -
Pure Python change, no
c2pa-rsorc2pa_c_ffiwork.
The accessors read from the same parsed dict thatget_active_manifest()already populates viaReader.json(). No new FFI symbols and no changes to the native library.
Non-goals / out of scope for this proposal
- No new public types exported from
c2pa/__init__.py. - No typed-schema conversion of returned dicts.
- No changes to
Reader.json()/Reader.detailed_json()/crjson()output. - No enum coercion (see decision 4).
- No changes to
c2pa-rs. - Ancillary ideas — a validation-category enum on top of
validation_status, a Settings helper for the official trust list, anis_ai_digital_source_type()classifier — are deliberately kept separate. Happy to pursue any of them in follow-up issues if the maintainers see value.
Activity
tmathern commentedon Jul 2, 2026
Can you elaborate the contract of
get_ingredients? What would it return? What happens if those ingredients have provenance themselves?Also, what does "sub-structure present" in your behavior contract table mean? Can you update the table you made to show also the parameters you'd pass in to those functions, for completeness?
HamzaJavaid-gh commentedon Jul 4, 2026
Thanks for the quick look. Answering in order.
get_ingredients() contract
Returns Optional[list[dict]]. Each dict is a pass-through of one entry in the target manifest's ingredients list, exactly as it appears in Reader.json(). No field renaming, no reshaping, no field change. Concretely, that means each dict may carry any of the fields defined on the Ingredient struct in c2pa-rs (each is optional and omitted from the JSON when unset):
title, format, document_id, instance_id, provenance, thumbnail, hash, relationship, active_manifest, validation_status, validation_results, data, description, informational_URI, metadata, data_types, manifest_data, label, ocsp_responses.Behavior:
Returns [] (not None) when the target manifest has no ingredients key, or when ingredients is an empty list.
Returns None in the same cases where Reader.get_active_manifest() would return None (applies when manifest_label=None — in normal operation with a successfully-constructed Reader this is rare, since unsigned assets raise C2paError.ManifestNotFound during Reader construction rather than reaching the accessor).
Raises KeyError when manifest_label is a non-None string that doesn't name any manifest in the store — same behavior as Reader.get_manifest(label) today.
Ingredients that have provenance themselves
This is exactly the case the manifest_label parameter is designed to cover. When an ingredient has its own signed C2PA data, the ingredient dict carries:
I'll walk into ingredient provenance by re-invoking the accessors with the ingredient's own label:
No special recursive helper in the accessor, the manifest_label parameter already gives consumers everything they need, and it mirrors how Reader.get_manifest(label) is used today. All the fields consumers need for the recursive walk (active_manifest, validation_status, validation_results, manifest_data) come through the pass-through so nothing is lost.
If you'd prefer a different shape for the recursive walk case, happy to follow up on this contract
HamzaJavaid-gh commentedon Jul 4, 2026
Contract table - updated
manifest_labelget_actions()get_signing_info()get_ingredients()Nonelist[dict]dictlist[dict]None[]None[]NoneNoneNoneNonestrnaming a manifestlist[dict]dictlist[dict]strnaming a manifest[]None[]strnot naming any manifestKeyError(fromget_manifest)KeyErrorKeyErrorC2paError(matchesReader.json())C2paErrorC2paErrorHappy to update the issue body with the revised table inline if you'd like, otherwise I'll fold the corrections into the PR docs.
tmathern commentedon Jul 7, 2026
Before diving deep into this, I also want to point out that there is a new API: https://github.com/contentauth/c2pa-python/blob/main/src/c2pa/c2pa.py#L2499-L2518. This one emits crJson, which has been specified in https://spec.c2pa.org/specifications/specifications/2.4/crJSON/crjson-format.html.
HamzaJavaid-gh commentedon Jul 7, 2026
Thanks for the pointer. I missed this part.
You're right that this covers the use case. Every piece of data the three proposed accessors were designed to reach is already in crJSON, and in a more structured form than what Reader.json() provides.
I'm leaning towards closing this, if you agree or any shape you'd like to discuss further, feel free.
Appreciate you taking the time to point at crJSON before we invested in that direction.
tmathern commentedon Jul 10, 2026
Sounds good! You can close, and always reopen if more comes up.