Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/m

## API reference documentation

See [the section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation).
Documentation is published at [github.io/c2pa-python/api/c2pa](https://contentauth.github.io/c2pa-python/api/c2pa/index.html).

To build documentation locally, refer to [this section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation).

## Contributing

Expand Down
6 changes: 5 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Python example code
# Python example code

The `examples` directory contains some small examples of using the Python library.
The examples use asset files from the `tests/fixtures` directory, save the resulting signed assets to the temporary `output` directory, and display manifest store data and other output to the console.
Expand Down Expand Up @@ -96,3 +96,7 @@ In this example, `SignerInfo` creates a `Signer` object that signs the manifest.
```bash
python examples/sign_info.py
```

## Backend application example

[c2pa-python-example](https://github.com/contentauth/c2pa-python-example) is an example of a simple application that accepts an uploaded JPEG image file, attaches a C2PA manifest, and signs it using a certificate. The app uses the CAI Python library and the Flask Python framework to implement a back-end REST endpoint; it does not have an HTML front-end, so you have to use something like curl to access it. This example is a development setup and should not be deployed as-is to a production environment.
269 changes: 181 additions & 88 deletions src/c2pa/c2pa.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,6 @@
'c2pa_reader_remote_url',
]

# TODO Bindings:
# c2pa_reader_is_embedded
# c2pa_reader_remote_url


def _validate_library_exports(lib):
"""Validate that all required functions are present in the loaded library.
Expand Down Expand Up @@ -537,71 +533,118 @@ def _setup_function(func, argtypes, restype=None):


class C2paError(Exception):
"""Exception raised for C2PA errors."""
"""Exception raised for C2PA errors.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixing error class hierarchy to leverage typed errors better, especially in the new Reader factory method


This is the base class for all C2PA exceptions. Catching C2paError will
catch all typed C2PA exceptions (e.g., C2paError.ManifestNotFound).
"""

def __init__(self, message: str = ""):
self.message = message
super().__init__(message)

class Assertion(Exception):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specific exceptions (all the types nested in the parent class) got un-nested from here so they can inherit from the base C2pa exception and get the C2paException type.

"""Exception raised for assertion errors."""
pass

class AssertionNotFound(Exception):
"""Exception raised when an assertion is not found."""
pass
# Define typed exception subclasses that inherit from C2paError
# These are attached to C2paError as class attributes for backward compatibility
# (eg., C2paError.ManifestNotFound), and also to ensure properly inheritance hierarchy

class _C2paAssertion(C2paError):
"""Exception raised for assertion errors."""
pass


class _C2paAssertionNotFound(C2paError):
"""Exception raised when an assertion is not found."""
pass


class _C2paDecoding(C2paError):
"""Exception raised for decoding errors."""
pass


class _C2paEncoding(C2paError):
"""Exception raised for encoding errors."""
pass


class _C2paFileNotFound(C2paError):
"""Exception raised when a file is not found."""
pass


class _C2paIo(C2paError):
"""Exception raised for IO errors."""
pass


class _C2paJson(C2paError):
"""Exception raised for JSON errors."""
pass


class _C2paManifest(C2paError):
"""Exception raised for manifest errors."""
pass


class _C2paManifestNotFound(C2paError):
"""
Exception raised when a manifest is not found,
aka there is no C2PA metadata to read
aka there is no JUMBF data to read.
"""
pass


class Decoding(Exception):
"""Exception raised for decoding errors."""
pass
class _C2paNotSupported(C2paError):
"""Exception raised for unsupported operations."""
pass

class Encoding(Exception):
"""Exception raised for encoding errors."""
pass

class FileNotFound(Exception):
"""Exception raised when a file is not found."""
pass
class _C2paOther(C2paError):
"""Exception raised for other errors."""
pass

class Io(Exception):
"""Exception raised for IO errors."""
pass

class Json(Exception):
"""Exception raised for JSON errors."""
pass
class _C2paRemoteManifest(C2paError):
"""Exception raised for remote manifest errors."""
pass

class Manifest(Exception):
"""Exception raised for manifest errors."""
pass

class ManifestNotFound(Exception):
"""Exception raised when a manifest is not found."""
pass
class _C2paResourceNotFound(C2paError):
"""Exception raised when a resource is not found."""
pass

class NotSupported(Exception):
"""Exception raised for unsupported operations."""
pass

class Other(Exception):
"""Exception raised for other errors."""
pass
class _C2paSignature(C2paError):
"""Exception raised for signature errors."""
pass

class RemoteManifest(Exception):
"""Exception raised for remote manifest errors."""
pass

class ResourceNotFound(Exception):
"""Exception raised when a resource is not found."""
pass
class _C2paVerify(C2paError):
"""Exception raised for verification errors."""
pass

class Signature(Exception):
"""Exception raised for signature errors."""
pass

class Verify(Exception):
"""Exception raised for verification errors."""
pass
# Attach exception subclasses to C2paError for backward compatibility
# Preserves behavior for exception catching like except C2paError.ManifestNotFound,
# also reduces imports (think of it as an alias of sorts)
C2paError.Assertion = _C2paAssertion
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An we still can use the parent type for general error catching!

C2paError.AssertionNotFound = _C2paAssertionNotFound
C2paError.Decoding = _C2paDecoding
C2paError.Encoding = _C2paEncoding
C2paError.FileNotFound = _C2paFileNotFound
C2paError.Io = _C2paIo
C2paError.Json = _C2paJson
C2paError.Manifest = _C2paManifest
C2paError.ManifestNotFound = _C2paManifestNotFound
C2paError.NotSupported = _C2paNotSupported
C2paError.Other = _C2paOther
C2paError.RemoteManifest = _C2paRemoteManifest
C2paError.ResourceNotFound = _C2paResourceNotFound
C2paError.Signature = _C2paSignature
C2paError.Verify = _C2paVerify


class _StringContainer:
Expand Down Expand Up @@ -656,60 +699,106 @@ def _convert_to_py_string(value) -> str:
return py_string


def _raise_typed_c2pa_error(error_str: str) -> None:
"""Parse an error string and raise the appropriate typed C2paError.

Error strings from the native library have the format "ErrorType: message".
This function parses the error type and raises the corresponding
C2paError subclass with the full original error string as the message.

Args:
error_str: The error string from the native library

Raises:
C2paError subclass: The appropriate typed exception based on error_str
"""
# Error format from native library is "ErrorType: message" or "ErrorType message"
# Try splitting on ": " first (colon-space), then fall back to space only
if ': ' in error_str:
parts = error_str.split(': ', 1)
else:
parts = error_str.split(' ', 1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the c api is inconsistent here, we should add an issue to fix it so that we always use the colon

if len(parts) > 1:
error_type = parts[0]
# Use the full error string as the message for backward compatibility
if error_type == "Assertion":
raise C2paError.Assertion(error_str)
elif error_type == "AssertionNotFound":
raise C2paError.AssertionNotFound(error_str)
elif error_type == "Decoding":
raise C2paError.Decoding(error_str)
elif error_type == "Encoding":
raise C2paError.Encoding(error_str)
elif error_type == "FileNotFound":
raise C2paError.FileNotFound(error_str)
elif error_type == "Io":
raise C2paError.Io(error_str)
elif error_type == "Json":
raise C2paError.Json(error_str)
elif error_type == "Manifest":
raise C2paError.Manifest(error_str)
elif error_type == "ManifestNotFound":
raise C2paError.ManifestNotFound(error_str)
elif error_type == "NotSupported":
raise C2paError.NotSupported(error_str)
elif error_type == "Other":
raise C2paError.Other(error_str)
elif error_type == "RemoteManifest":
raise C2paError.RemoteManifest(error_str)
elif error_type == "ResourceNotFound":
raise C2paError.ResourceNotFound(error_str)
elif error_type == "Signature":
raise C2paError.Signature(error_str)
elif error_type == "Verify":
raise C2paError.Verify(error_str)
# If no recognized error type, raise base C2paError
raise C2paError(error_str)


def _parse_operation_result_for_error(
result: ctypes.c_void_p | None,
check_error: bool = True) -> Optional[str]:
"""Helper function to handle string results from C2PA functions."""
"""Helper function to handle string results from C2PA functions.

When result is falsy and check_error is True, this function retrieves the
error from the native library, parses it, and raises a typed C2paError.

When result is truthy (a pointer to an error string), this function
converts it to a Python string, parses it, and raises a typed C2paError.

Args:
result: A pointer to a result string, or None/falsy on error
check_error: Whether to check for errors when result is falsy

Returns:
None if no error occurred

Raises:
C2paError subclass: The appropriate typed exception if an error occurred
"""
if not result: # pragma: no cover
if check_error:
error = _lib.c2pa_error()
if error:
error_str = ctypes.cast(
error, ctypes.c_char_p).value.decode('utf-8')
_lib.c2pa_string_free(error)
parts = error_str.split(' ', 1)
if len(parts) > 1:
error_type, message = parts
if error_type == "Assertion":
raise C2paError.Assertion(message)
elif error_type == "AssertionNotFound":
raise C2paError.AssertionNotFound(message)
elif error_type == "Decoding":
raise C2paError.Decoding(message)
elif error_type == "Encoding":
raise C2paError.Encoding(message)
elif error_type == "FileNotFound":
raise C2paError.FileNotFound(message)
elif error_type == "Io":
raise C2paError.Io(message)
elif error_type == "Json":
raise C2paError.Json(message)
elif error_type == "Manifest":
raise C2paError.Manifest(message)
elif error_type == "ManifestNotFound":
raise C2paError.ManifestNotFound(message)
elif error_type == "NotSupported":
raise C2paError.NotSupported(message)
elif error_type == "Other":
raise C2paError.Other(message)
elif error_type == "RemoteManifest":
raise C2paError.RemoteManifest(message)
elif error_type == "ResourceNotFound":
raise C2paError.ResourceNotFound(message)
elif error_type == "Signature":
raise C2paError.Signature(message)
elif error_type == "Verify":
raise C2paError.Verify(message)
return error_str
_raise_typed_c2pa_error(error_str)
return None

# In the case result would be a string already (error message)
return _convert_to_py_string(result)
error_str = _convert_to_py_string(result)
if error_str:
_raise_typed_c2pa_error(error_str)
return None


def sdk_version() -> str:
"""
Returns the underlying c2pa-rs/c2pa-c-ffi version string
c2pa-rs and c2pa-c-ffi versions are in lockstep release,
so the version string is the same for both and we return
the shared semantic version number.
"""
vstr = version()
# Example: "c2pa-c/0.60.1 c2pa-rs/0.60.1"
Expand All @@ -721,7 +810,11 @@ def sdk_version() -> str:


def version() -> str:
"""Get the C2PA library version."""
"""
Get the C2PA library version with the fully qualified names
of the native core libraries (library names and semantic version
numbers).
"""
result = _lib.c2pa_version()
return _convert_to_py_string(result)

Expand Down Expand Up @@ -2622,7 +2715,7 @@ def set_intent(
- EDIT: Edit of a pre-existing parent asset.
Must have a parent ingredient.
- UPDATE: Restricted version of Edit for non-editorial changes.
Must have only one ingredient as a parent.
Must have only one ingredient, as a parent.

Args:
intent: The builder intent (C2paBuilderIntent enum value)
Expand Down
8 changes: 8 additions & 0 deletions tests/test_unit_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ def test_can_retrieve_reader_supported_mimetypes(self):

self.assertEqual(result1, result2)

def test_stream_read_nothing_to_read(self):
# The ingredient test file has no manifest
# So if we instantiate directly, the Reader instance should throw
with open(INGREDIENT_TEST_FILE, "rb") as file:
with self.assertRaises(Error) as context:
reader = Reader("image/jpeg", file)
self.assertIn("ManifestNotFound: no JUMBF data found", str(context.exception))

def test_stream_read(self):
with open(self.testPath, "rb") as file:
reader = Reader("image/jpeg", file)
Expand Down