diff --git a/Makefile b/Makefile index 4df3a42e..4070ff0e 100644 --- a/Makefile +++ b/Makefile @@ -3,48 +3,68 @@ # Version of C2PA to use C2PA_VERSION := $(shell cat c2pa-native-version.txt) +# Python interpreter. Honors an active virtualenv ($VIRTUAL_ENV), then a local +# ./.venv, then falls back to python3 on PATH. Override with: make PYTHON=... +ifndef PYTHON + ifdef VIRTUAL_ENV + PYTHON := $(VIRTUAL_ENV)/bin/python + else ifneq ($(wildcard .venv/bin/python),) + PYTHON := .venv/bin/python + $(warning .venv exists but is not activated; using ./.venv/bin/python. Run 'source .venv/bin/activate' or override with PYTHON=...) + else + PYTHON := python3 + endif +endif + # Start from clean env: Delete `.venv`, then `python3 -m venv .venv` # Pre-requisite: Python virtual environment is active (source .venv/bin/activate) # Run Pytest tests in virtualenv: .venv/bin/pytest tests/test_unit_tests.py -v +# Creates the local virtualenv at the canonical ./.venv path if it does not exist. +# Activation must happen in shell independently: +# make create-venv && source .venv/bin/activate +create-venv: + test -d .venv || python3 -m venv .venv + @echo "Virtualenv ready at ./.venv -- activate with: source .venv/bin/activate" + # Removes build artifacts, distribution files, and other generated content clean: rm -rf artifacts/ build/ dist/ # Performs a complete cleanup including uninstalling the c2pa package and clearing pip cache clean-c2pa-env: clean - python3 -m pip uninstall -y c2pa - python3 -m pip cache purge + $(PYTHON) -m pip uninstall -y c2pa + $(PYTHON) -m pip cache purge # Installs all required dependencies from requirements.txt and requirements-dev.txt install-deps: - python3 -m pip install -r requirements.txt - python3 -m pip install -r requirements-dev.txt + $(PYTHON) -m pip install -r requirements.txt + $(PYTHON) -m pip install -r requirements-dev.txt # Installs the package in development mode build-python: - python3 -m pip install -e . + $(PYTHON) -m pip install -e . # Performs a complete rebuild of the development environment rebuild: clean-c2pa-env install-deps download-native-artifacts build-python @echo "Development rebuild done" run-examples: - python3 ./examples/sign.py - python3 ./examples/sign_info.py - python3 ./examples/no_thumbnails.py - python3 ./examples/training.py + $(PYTHON) ./examples/sign.py + $(PYTHON) ./examples/sign_info.py + $(PYTHON) ./examples/no_thumbnails.py + $(PYTHON) ./examples/training.py rm -rf output/ # Runs the examples, then the unit tests test: make run-examples - python3 ./tests/test_unit_tests.py - python3 ./tests/test_unit_tests_threaded.py + $(PYTHON) ./tests/test_unit_tests.py + $(PYTHON) ./tests/test_unit_tests_threaded.py # Runs benchmarks in the venv benchmark: - python3 -m pytest tests/benchmark.py -v + $(PYTHON) -m pytest tests/benchmark.py -v # Tests building and installing a local wheel package # Downloads required artifacts, builds the wheel, installs it, and verifies the installation @@ -52,17 +72,17 @@ test-local-wheel-build: # Clean any existing builds rm -rf build/ dist/ # Download artifacts and place them where they should go - python3 scripts/download_artifacts.py $(C2PA_VERSION) + $(PYTHON) scripts/download_artifacts.py $(C2PA_VERSION) # Install Python - python3 -m pip install -r requirements.txt - python3 -m pip install -r requirements-dev.txt - python3 -m build --wheel + $(PYTHON) -m pip install -r requirements.txt + $(PYTHON) -m pip install -r requirements-dev.txt + $(PYTHON) -m build --wheel # Install local build in venv - pip install $$(ls dist/*.whl) + $(PYTHON) -m pip install $$(ls dist/*.whl) # Verify installation in local venv - python3 -c "import c2pa; print('C2PA package installed at:', c2pa.__file__)" + $(PYTHON) -c "import c2pa; print('C2PA package installed at:', c2pa.__file__)" # Verify wheel structure - twine check dist/* + $(PYTHON) -m twine check dist/* # Tests building and installing a local source distribution package # Downloads required artifacts, builds the sdist, installs it, and verifies the installation @@ -70,42 +90,52 @@ test-local-sdist-build: # Clean any existing builds rm -rf build/ dist/ # Download artifacts and place them where they should go - python3 scripts/download_artifacts.py $(C2PA_VERSION) + $(PYTHON) scripts/download_artifacts.py $(C2PA_VERSION) # Install Python - python3 -m pip install -r requirements.txt - python3 -m pip install -r requirements-dev.txt + $(PYTHON) -m pip install -r requirements.txt + $(PYTHON) -m pip install -r requirements-dev.txt # Build sdist package - python3 setup.py sdist + $(PYTHON) setup.py sdist # Install local build in venv - pip install $$(ls dist/*.tar.gz) + $(PYTHON) -m pip install $$(ls dist/*.tar.gz) # Verify installation in local venv - python3 -c "import c2pa; print('C2PA package installed at:', c2pa.__file__)" + $(PYTHON) -c "import c2pa; print('C2PA package installed at:', c2pa.__file__)" # Verify sdist structure - twine check dist/* + $(PYTHON) -m twine check dist/* # Verifies the wheel build process and checks the built package and its metadata verify-wheel-build: rm -rf build/ dist/ src/*.egg-info/ - python3 -m build - twine check dist/* + $(PYTHON) -m build + $(PYTHON) -m twine check dist/* # Manually publishes the package to PyPI after creating a release publish: release - python3 -m pip install twine - python3 -m twine upload dist/* + $(PYTHON) -m pip install twine + $(PYTHON) -m twine upload dist/* # Code analysis check-format: - python3 -m py_compile src/c2pa/c2pa.py - flake8 src/c2pa/c2pa.py + $(PYTHON) -m py_compile src/c2pa/c2pa.py + $(PYTHON) -m flake8 --extend-ignore=E501 src/c2pa/c2pa.py # Formats Python source code using autopep8 with aggressive settings format: - autopep8 --aggressive --aggressive --in-place src/c2pa/c2pa.py + $(PYTHON) -m autopep8 --aggressive --aggressive --in-place src/c2pa/c2pa.py # Downloads the required native artifacts for the specified version download-native-artifacts: - python3 scripts/download_artifacts.py $(C2PA_VERSION) + $(PYTHON) scripts/download_artifacts.py $(C2PA_VERSION) + +# Builds the native library from local c2pa-rs checkout and install it. +# Requires C2PA_RS_PATH to point at the c2pa-rs sources and a working Rust toolchain. +# Replaces the prebuilt artifacts from download-native-artifacts. +# --clean forces a full `cargo clean`, drop it for faster incremental rebuilds. +# Pass EXTRA_BUILD_ARGS="--debug" to build the debug profile (release is the default). +# Usage: make build-from-source C2PA_RS_PATH=/path/to/c2pa-rs +build-from-source: + $(PYTHON) scripts/build_local_artifacts.py --clean $(EXTRA_BUILD_ARGS) + $(PYTHON) -m pip install -e . # Build API documentation with Sphinx docs: diff --git a/README.md b/README.md index f4c0e210..0328ee93 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,48 @@ To use the module in Python code, import the module like this: import c2pa ``` +## Building from local c2pa-rs sources + +### Using a virtual environment with local builds + +The `make` targets honor an active virtualenv. Create a virtual environment `./.venv` and activate it before running them so commands use the project interpreter rather than the global Python interpreter: + +```sh +make create-venv && source .venv/bin/activate +``` + +### Build steps + +By default the build downloads a prebuilt native library from a [c2pa-rs](https://github.com/contentauth/c2pa-rs) release. To test the Python bindings against a local, unreleased c2pa-rs checkout, you can instead build the native library from source. + +Prerequisites: + +- A local clone of [c2pa-rs](https://github.com/contentauth/c2pa-rs). +- The [Rust toolchain](https://rust-lang.org/tools/install/) (`cargo` on your `PATH`). + +Point `C2PA_RS_PATH` at your c2pa-rs checkout and run the `build-from-source` target: + +```sh +export C2PA_RS_PATH=/path/to/c2pa-rs +make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH +``` + +This does a clean build of the `c2pa-c-ffi` crate (with the `file_io` feature, which the Python wrapper requires), stages the resulting library under both `artifacts/` and `src/c2pa/libs/`, and installs the package in editable mode, replacing any prebuilt artifacts from `make download-native-artifacts`. The release profile is used by default; to build the debug profile instead, pass `EXTRA_BUILD_ARGS="--debug"`: + +```sh +make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH EXTRA_BUILD_ARGS="--debug" +``` + +### Note on targets for macOS + +On macOS this produces a universal (arm64+x86_64) library by default, which requires both Rust targets: + +```sh +rustup target add aarch64-apple-darwin x86_64-apple-darwin +``` + +To build a single-architecture library instead, set `C2PA_LIBS_PLATFORM` to a specific platform (for example `aarch64-apple-darwin`). + ## Examples See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/main/examples) for some helpful examples: diff --git a/requirements-dev.txt b/requirements-dev.txt index ab7ae804..ae6c7a61 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,12 +12,12 @@ pytest-benchmark>=5.1.0 requests>=2.0.0 # Code formatting -autopep8==2.0.4 # For automatic code formatting +autopep8>=2.3.0 flake8==7.3.0 # Test dependencies (for callback signers) -cryptography==47.0.0 - +cryptography>=47.0.0 + # Documentation Sphinx>=7.3.0 sphinx-autoapi>=3.0.0 diff --git a/scripts/build_local_artifacts.py b/scripts/build_local_artifacts.py new file mode 100644 index 00000000..2e833a1d --- /dev/null +++ b/scripts/build_local_artifacts.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 + +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +"""Build the native c2pa C FFI library from a local c2pa-rs checkout. + +This is the counterpart to download_artifacts.py: instead of +downloading a released prebuilt c2pa-rs native library, +it compiles from local sources and places the built library +where setup.py expects it (artifacts/{platform_id}/). + +The path to the c2pa-rs sources is taken from the C2PA_RS_PATH environment +variable (or the first positional argument). + +Pass --clean to run a full `cargo clean` first, forcing a from-scratch rebuild. +Pass --debug to build the debug profile instead of the default release profile. +""" + +import os +import sys +import shutil +import argparse +import platform +import subprocess +from pathlib import Path + +# The crate in c2pa-rs that produces the native library. +FFI_PACKAGE = "c2pa-c-ffi" +# Extra c2pa-c-ffi features to enable on top of the crate defaults +FFI_FEATURES = "file_io" +ROOT_ARTIFACTS_DIR = Path("artifacts") +# Where the package loads the library from at runtime for an editable install. +PACKAGE_LIBS_DIR = Path("src/c2pa/libs") + +# Library file name per OS (matches what setup.py/lib.py load at runtime). +LIB_NAMES = { + "darwin": "libc2pa_c.dylib", + "linux": "libc2pa_c.so", + "windows": "c2pa_c.dll", +} + + +def get_platform_identifier(): + """Get the platform identifier (arch-os) for the host system. + """ + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "darwin": + if machine == "arm64": + return "aarch64-apple-darwin" + elif machine == "x86_64": + return "x86_64-apple-darwin" + else: + return "universal-apple-darwin" + elif system == "windows": + return "x86_64-pc-windows-msvc" + elif system == "linux": + if machine in ["arm64", "aarch64"]: + return "aarch64-unknown-linux-gnu" + else: + return "x86_64-unknown-linux-gnu" + else: + raise ValueError(f"Unsupported operating system: {system}") + + +def resolve_c2pa_rs_path(cli_path=None): + """Resolve and validate the path to the local c2pa-rs sources.""" + raw = os.environ.get("C2PA_RS_PATH") or cli_path + + if not raw: + print( + "Error: C2PA_RS_PATH is not set.\n" + "Set it to the path of the local c2pa-rs checkout, for example:\n" + " export C2PA_RS_PATH=/path/to/c2pa-rs\n" + " make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH" + ) + sys.exit(1) + + path = Path(raw).expanduser().resolve() + if not path.is_dir(): + print(f"Error: C2PA_RS_PATH is not a directory: {path}") + sys.exit(1) + + if not (path / "c2pa_c_ffi" / "Cargo.toml").is_file(): + print( + f"Error: {path} does not look like a c2pa-rs checkout." + ) + sys.exit(1) + + return path + + +def clean_workspace(c2pa_rs_path): + """Remove all prior c2pa-rs build artifacts (cleans workspace). + """ + cmd = ["cargo", "clean"] + print(f"Running: {' '.join(cmd)} (cwd={c2pa_rs_path})") + try: + subprocess.run(cmd, cwd=c2pa_rs_path, check=True) + except FileNotFoundError: + print( + "Error: 'cargo' was not found. Install the Rust toolchain " + "(https://rust-lang.org/tools/install/) and ensure cargo is on PATH:\n" + ' export PATH="$HOME/.cargo/bin:$PATH"' + ) + sys.exit(1) + except subprocess.CalledProcessError as e: + print(f"Error: cargo clean failed (exit code {e.returncode}).") + sys.exit(e.returncode) + + +def run_cargo(c2pa_rs_path, extra_args=None, debug=False): + """Build the FFI crate in the c2pa-rs checkout (release unless debug=True).""" + cmd = ["cargo", "build", "-p", FFI_PACKAGE, "--features", FFI_FEATURES] + if not debug: + cmd.insert(2, "--release") + if extra_args: + cmd += extra_args + print(f"Running: {' '.join(cmd)} (cwd={c2pa_rs_path})") + try: + subprocess.run(cmd, cwd=c2pa_rs_path, check=True) + except FileNotFoundError: + print( + "Error: 'cargo' was not found. Install the Rust toolchain " + "(https://rust-lang.org/tools/install/) and ensure cargo is on PATH:\n" + ' export PATH="$HOME/.cargo/bin:$PATH"' + ) + sys.exit(1) + except subprocess.CalledProcessError as e: + print(f"Error: cargo build failed (exit code {e.returncode}).") + sys.exit(e.returncode) + + +def build_universal_macos(c2pa_rs_path, debug=False): + """Build both macOS arches and lipo them into one universal dylib. + Returns the path to the universal libc2pa_c.dylib. + """ + profile = "debug" if debug else "release" + triples = ["aarch64-apple-darwin", "x86_64-apple-darwin"] + per_arch_libs = [] + for triple in triples: + run_cargo(c2pa_rs_path, ["--target", triple], debug=debug) + lib = c2pa_rs_path / "target" / triple / profile / LIB_NAMES["darwin"] + if not lib.is_file(): + print( + f"Error: expected built library not found: {lib}\n" + ) + sys.exit(1) + per_arch_libs.append(lib) + + universal = c2pa_rs_path / "target" / profile / LIB_NAMES["darwin"] + universal.parent.mkdir(parents=True, exist_ok=True) + lipo_cmd = ["lipo", "-create", *map(str, per_arch_libs), + "-output", str(universal)] + print(f"Running: {' '.join(lipo_cmd)}") + try: + subprocess.run(lipo_cmd, check=True) + except FileNotFoundError: + print("Error: 'lipo' was not found (it ships with the Xcode command line tools).") + sys.exit(1) + except subprocess.CalledProcessError as e: + print(f"Error: lipo failed (exit code {e.returncode}).") + sys.exit(e.returncode) + return universal + + +def build_native(c2pa_rs_path, debug=False): + """Build the FFI crate for the host arch. Returns the built library path.""" + profile = "debug" if debug else "release" + run_cargo(c2pa_rs_path, debug=debug) + lib_name = LIB_NAMES[platform.system().lower()] + lib = c2pa_rs_path / "target" / profile / lib_name + if not lib.is_file(): + print(f"Error: expected built library not found: {lib}") + sys.exit(1) + return lib + + +def copy_to_artifacts(lib_path, platform_id): + """Copy the built library into artifacts/{platform_id}""" + platform_dir = ROOT_ARTIFACTS_DIR / platform_id + if platform_dir.exists(): + shutil.rmtree(platform_dir) + platform_dir.mkdir(parents=True, exist_ok=True) + + dest = platform_dir / lib_path.name + shutil.copy2(lib_path, dest) + print(f"Copied {lib_path} -> {dest}") + return dest + + +def stage_into_package(lib_path): + """Copy the built library into src/c2pa/libs/. + """ + if PACKAGE_LIBS_DIR.exists(): + shutil.rmtree(PACKAGE_LIBS_DIR) + PACKAGE_LIBS_DIR.mkdir(parents=True, exist_ok=True) + + dest = PACKAGE_LIBS_DIR / lib_path.name + shutil.copy2(lib_path, dest) + print(f"Copied {lib_path} -> {dest}") + return dest + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Build the c2pa C FFI library from a local c2pa-rs checkout." + ) + parser.add_argument( + "c2pa_rs_path", + nargs="?", + help="Path to the local c2pa-rs sources (overridden by C2PA_RS_PATH).", + ) + parser.add_argument( + "--clean", + action="store_true", + help="Runs `cargo clean` first so local c2pa-rs is rebuilt.", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Build the FFI crate in debug profile instead of release.", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + c2pa_rs_path = resolve_c2pa_rs_path(args.c2pa_rs_path) + print(f"Using c2pa-rs sources at: {c2pa_rs_path}") + + system = platform.system().lower() + + # Determine the target platform id + env_platform = os.environ.get("C2PA_LIBS_PLATFORM") + if env_platform: + print(f"Using platform from environment variable C2PA_LIBS_PLATFORM: {env_platform}") + platform_id = env_platform + elif system == "darwin": + # Default macOS to a universal2 build. + platform_id = "universal-apple-darwin" + else: + platform_id = get_platform_identifier() + + print(f"Target platform: {platform_id}") + + # Optionally start from a fully clean workspace. + # Enabled via --clean. + if args.clean: + clean_workspace(c2pa_rs_path) + + if platform_id == "universal-apple-darwin": + lib_path = build_universal_macos(c2pa_rs_path, args.debug) + else: + lib_path = build_native(c2pa_rs_path, args.debug) + + copy_to_artifacts(lib_path, platform_id) + stage_into_package(lib_path) + print("\nLocal native library built and staged successfully.") + print(f" c2pa-rs: {c2pa_rs_path}") + print(f" platform: {platform_id}") + print(f" profile: {'debug' if args.debug else 'release'}") + + +if __name__ == "__main__": + main() diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index c33450dc..71cfb80f 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -253,7 +253,8 @@ def _ensure_valid_state(self): if self._lifecycle_state != LifecycleState.ACTIVE: raise C2paError(f"{name} is not properly initialized") if not self._handle: - raise C2paError(f"{name} has an invalid internal state (active but no handle)") + raise C2paError( + f"{name} has an invalid internal state (active but no handle)") _clear_error_state() def _release(self): @@ -1011,7 +1012,11 @@ def _parse_operation_result_for_error( return None -def _check_ffi_operation_result(result, fallback_msg, *, check=lambda r: not r): +def _check_ffi_operation_result( + result, + fallback_msg, + *, + check=lambda r: not r): """Check an FFI native call result and raise C2paError if it indicates failure. Args: @@ -1036,7 +1041,8 @@ def _check_ffi_operation_result(result, fallback_msg, *, check=lambda r: not r): return result -def _to_utf8_bytes(data: Union[str, dict], error_context: str = "input") -> bytes: +def _to_utf8_bytes(data: Union[str, dict], + error_context: str = "input") -> bytes: """Convert a string or dict to UTF-8 bytes. If data is a dict, it is serialized to JSON first. @@ -1059,8 +1065,8 @@ def _to_utf8_bytes(data: Union[str, dict], error_context: str = "input") -> byte raise C2paError.Json(f"Failed to serialize {error_context}: {e}") if not isinstance(data, str): raise C2paError.Encoding( - f"Expected str or dict for {error_context}, got {type(data).__name__}" - ) + f"Expected str or dict for {error_context}, " + f"got {type(data).__name__}") try: return data.encode('utf-8') except UnicodeError as e: @@ -1146,7 +1152,10 @@ def load_settings(settings: Union[str, dict], format: str = "json") -> None: raise C2paError(f"Failed to encode settings to UTF-8: {e}") result = _lib.c2pa_load_settings(settings_bytes, format_bytes) - _check_ffi_operation_result(result, "Error loading settings", check=lambda r: r != 0) + _check_ffi_operation_result( + result, + "Error loading settings", + check=lambda r: r != 0) return result @@ -1219,7 +1228,8 @@ def read_ingredient_file( result = _lib.c2pa_read_ingredient_file( container._path_str, container._data_dir_str) - _check_ffi_operation_result(result, "Error reading ingredient file {}".format(path)) + _check_ffi_operation_result( + result, "Error reading ingredient file {}".format(path)) return _convert_to_py_string(result) @@ -1258,7 +1268,8 @@ def read_file(path: Union[str, Path], container._data_dir_str = str(data_dir).encode('utf-8') result = _lib.c2pa_read_file(container._path_str, container._data_dir_str) - _check_ffi_operation_result(result, "Error during read of manifest from file {}".format(path)) + _check_ffi_operation_result( + result, "Error during read of manifest from file {}".format(path)) return _convert_to_py_string(result) @@ -1419,7 +1430,6 @@ class Settings(ManagedResource): apply settings to Reader/Builder operations. """ - def __init__(self): """Create new Settings with default values.""" super().__init__() @@ -1516,7 +1526,6 @@ def _c_settings(self): return self._handle - class ContextBuilder: """Fluent builder for Context. @@ -1574,7 +1583,6 @@ class Context(ManagedResource, ContextProvider): used directly again after that. """ - def __init__( self, settings: Optional['Settings'] = None, @@ -1718,7 +1726,6 @@ def execution_context(self): return self._handle - class Stream: # Class-level somewhat atomic counter for generating # unique stream IDs (useful for tracing streams usage in debug) @@ -2132,7 +2139,6 @@ class Reader(ManagedResource): Where `output` is either an in-memory stream or an opened file. """ - # Supported mimetypes cache _supported_mime_types_cache = None @@ -2163,8 +2169,7 @@ def get_supported_mime_types(cls) -> list[str]: C2paError: If there was an error retrieving the MIME types """ result, cls._supported_mime_types_cache = _get_supported_mime_types( - _lib.c2pa_reader_supported_mime_types, cls._supported_mime_types_cache - ) + _lib.c2pa_reader_supported_mime_types, cls._supported_mime_types_cache) return result @classmethod @@ -2347,8 +2352,9 @@ def _create_reader(self, format_bytes, stream_obj, else: if not isinstance(manifest_data, bytes): raise TypeError(Reader._ERROR_MESSAGES['manifest_error']) - manifest_array = (ctypes.c_ubyte * len(manifest_data)).from_buffer_copy( - manifest_data) + manifest_array = ( + ctypes.c_ubyte * + len(manifest_data)).from_buffer_copy(manifest_data) self._handle = ( _lib.c2pa_reader_from_manifest_data_and_stream( format_bytes, @@ -2358,9 +2364,9 @@ def _create_reader(self, format_bytes, stream_obj, ) ) - _check_ffi_operation_result(self._handle, - Reader._ERROR_MESSAGES['reader_error'].format("Unknown error") - ) + _check_ffi_operation_result( + self._handle, + Reader._ERROR_MESSAGES['reader_error'].format("Unknown error")) def _init_from_file(self, path, format_bytes, manifest_data=None): @@ -2422,10 +2428,10 @@ def _init_from_context(self, context, format_or_path, ) try: _check_ffi_operation_result(reader_ptr, - Reader._ERROR_MESSAGES[ - 'reader_error' - ].format("Unknown error") - ) + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) except Exception: if reader_ptr: ManagedResource._free_native_ptr(reader_ptr) @@ -2436,8 +2442,9 @@ def _init_from_context(self, context, format_or_path, raise TypeError( Reader._ERROR_MESSAGES[ 'manifest_error']) - manifest_array = (ctypes.c_ubyte * len(manifest_data)).from_buffer_copy( - manifest_data) + manifest_array = ( + ctypes.c_ubyte * + len(manifest_data)).from_buffer_copy(manifest_data) # Consume current reader, # with manifest data and stream (C FFI pattern), # to create a new one (switch out) @@ -2462,10 +2469,10 @@ def _init_from_context(self, context, format_or_path, self._handle = new_ptr _check_ffi_operation_result(new_ptr, - Reader._ERROR_MESSAGES[ - 'reader_error' - ].format("Unknown error") - ) + Reader._ERROR_MESSAGES[ + 'reader_error' + ].format("Unknown error") + ) self._lifecycle_state = LifecycleState.ACTIVE except Exception: @@ -2556,9 +2563,9 @@ def with_fragment(self, format: str, stream, if not new_ptr: self._mark_consumed() _check_ffi_operation_result(new_ptr, - Reader._ERROR_MESSAGES[ - 'fragment_error' - ].format("Unknown error")) + Reader._ERROR_MESSAGES[ + 'fragment_error' + ].format("Unknown error")) self._handle = new_ptr # Invalidate caches: processing a new BMFF fragment updates the native @@ -2594,7 +2601,7 @@ def json(self) -> str: result = _lib.c2pa_reader_json(self._handle) _check_ffi_operation_result(result, - "Error during manifest parsing in Reader") + "Error during manifest parsing in Reader") # Cache the result and return it self._manifest_json_str_cache = _convert_to_py_string(result) @@ -2619,8 +2626,8 @@ def detailed_json(self) -> str: self._ensure_valid_state() result = _lib.c2pa_reader_detailed_json(self._handle) - _check_ffi_operation_result(result, - "Error during detailed manifest parsing in Reader") + _check_ffi_operation_result( + result, "Error during detailed manifest parsing in Reader") return _convert_to_py_string(result) @@ -2745,7 +2752,8 @@ def resource_to_stream(self, uri: str, stream: Any) -> int: result = _lib.c2pa_reader_resource_to_stream( self._handle, uri_str, stream_obj._stream) - _check_ffi_operation_result(result, + _check_ffi_operation_result( + result, "Error during resource {} to stream conversion".format(uri), check=lambda r: r < 0) @@ -2794,7 +2802,6 @@ def get_remote_url(self) -> Optional[str]: class Signer(ManagedResource): """High-level wrapper for C2PA Signer operations.""" - # Class-level error messages to avoid multiple creation _ERROR_MESSAGES = { 'closed_error': "Signer is closed", @@ -2827,8 +2834,8 @@ def from_info(cls, signer_info: C2paSignerInfo) -> 'Signer': signer_ptr = _lib.c2pa_signer_from_info(ctypes.byref(signer_info)) - _check_ffi_operation_result(signer_ptr, - "Failed to create signer from configured signer_info") + _check_ffi_operation_result( + signer_ptr, "Failed to create signer from configured signer_info") return cls(signer_ptr) @@ -2959,7 +2966,7 @@ def wrapped_callback( ) _check_ffi_operation_result(signer_ptr, - "Failed to create signer") + "Failed to create signer") # Create and return the signer instance with the callback signer_instance = cls(signer_ptr) @@ -3009,8 +3016,10 @@ def reserve_size(self) -> int: result = _lib.c2pa_signer_reserve_size(self._handle) - _check_ffi_operation_result(result, - "Failed to get reserve size", check=lambda r: r < 0) + _check_ffi_operation_result( + result, + "Failed to get reserve size", + check=lambda r: r < 0) return result @@ -3018,7 +3027,6 @@ def reserve_size(self) -> int: class Builder(ManagedResource): """High-level wrapper for C2PA Builder operations.""" - # Supported mimetypes cache _supported_mime_types_cache = None @@ -3051,8 +3059,7 @@ def get_supported_mime_types(cls) -> list[str]: C2paError: If there was an error retrieving the MIME types """ result, cls._supported_mime_types_cache = _get_supported_mime_types( - _lib.c2pa_builder_supported_mime_types, cls._supported_mime_types_cache - ) + _lib.c2pa_builder_supported_mime_types, cls._supported_mime_types_cache) return result @classmethod @@ -3125,8 +3132,8 @@ def from_archive( ) _check_ffi_operation_result(builder._handle, - "Failed to create builder from archive" - ) + "Failed to create builder from archive" + ) builder._lifecycle_state = LifecycleState.ACTIVE return builder @@ -3178,11 +3185,9 @@ def __init__( else: self._handle = _lib.c2pa_builder_from_json(json_str) - _check_ffi_operation_result(self._handle, - Builder._ERROR_MESSAGES['builder_error'].format( - "Unknown error" - ) - ) + _check_ffi_operation_result( + self._handle, + Builder._ERROR_MESSAGES['builder_error'].format("Unknown error")) self._lifecycle_state = LifecycleState.ACTIVE @@ -3200,10 +3205,10 @@ def _init_from_context(self, context, json_str): ) try: _check_ffi_operation_result(builder_ptr, - Builder._ERROR_MESSAGES[ - 'builder_error' - ].format("Unknown error") - ) + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") + ) except Exception: if builder_ptr: ManagedResource._free_native_ptr(builder_ptr) @@ -3215,10 +3220,10 @@ def _init_from_context(self, context, json_str): self._handle = new_ptr _check_ffi_operation_result(new_ptr, - Builder._ERROR_MESSAGES[ - 'builder_error' - ].format("Unknown error") - ) + Builder._ERROR_MESSAGES[ + 'builder_error' + ].format("Unknown error") + ) def set_no_embed(self): """Set the no-embed flag. @@ -3247,7 +3252,8 @@ def set_remote_url(self, remote_url: str): url_bytes = _to_utf8_bytes(remote_url, "remote URL") result = _lib.c2pa_builder_set_remote_url(self._handle, url_bytes) - _check_ffi_operation_result(result, + _check_ffi_operation_result( + result, Builder._ERROR_MESSAGES['url_error'].format("Unknown error"), check=lambda r: r != 0) @@ -3285,7 +3291,8 @@ def set_intent( ctypes.c_uint(digital_source_type), ) - _check_ffi_operation_result(result, + _check_ffi_operation_result( + result, "Error setting intent for Builder: Unknown error", check=lambda r: r != 0) @@ -3307,10 +3314,9 @@ def add_resource(self, uri: str, stream: Any): result = _lib.c2pa_builder_add_resource( self._handle, uri_bytes, stream_obj._stream) - _check_ffi_operation_result(result, - Builder._ERROR_MESSAGES['resource_error'].format( - "Unknown error" - ), + _check_ffi_operation_result( + result, + Builder._ERROR_MESSAGES['resource_error'].format("Unknown error"), check=lambda r: r != 0) def add_ingredient( @@ -3375,10 +3381,9 @@ def add_ingredient_from_stream( ) ) - _check_ffi_operation_result(result, - Builder._ERROR_MESSAGES['ingredient_error'].format( - "Unknown error" - ), + _check_ffi_operation_result( + result, + Builder._ERROR_MESSAGES['ingredient_error'].format("Unknown error"), check=lambda r: r != 0) def add_ingredient_from_file_path( @@ -3444,10 +3449,9 @@ def add_action(self, action_json: Union[str, dict]) -> None: action_str = _to_utf8_bytes(action_json, "action JSON") result = _lib.c2pa_builder_add_action(self._handle, action_str) - _check_ffi_operation_result(result, - Builder._ERROR_MESSAGES['action_error'].format( - "Unknown error" - ), + _check_ffi_operation_result( + result, + Builder._ERROR_MESSAGES['action_error'].format("Unknown error"), check=lambda r: r != 0) def to_archive(self, stream: Any) -> None: @@ -3466,10 +3470,9 @@ def to_archive(self, stream: Any) -> None: result = _lib.c2pa_builder_to_archive( self._handle, stream_obj._stream) - _check_ffi_operation_result(result, - Builder._ERROR_MESSAGES["archive_error"].format( - "Unknown error" - ), + _check_ffi_operation_result( + result, + Builder._ERROR_MESSAGES["archive_error"].format("Unknown error"), check=lambda r: r != 0) def with_archive(self, stream: Any) -> 'Builder': @@ -3491,7 +3494,8 @@ def with_archive(self, stream: Any) -> 'Builder': with Stream(stream) as stream_obj: try: - new_ptr = _lib.c2pa_builder_with_archive(self._handle, stream_obj._stream) + new_ptr = _lib.c2pa_builder_with_archive( + self._handle, stream_obj._stream) except Exception as e: self._mark_consumed() raise C2paError( @@ -3499,7 +3503,8 @@ def with_archive(self, stream: Any) -> 'Builder': ) # Old handle consumed by FFI self._handle = new_ptr - _check_ffi_operation_result(new_ptr, "Failed to load archive into builder") + _check_ffi_operation_result( + new_ptr, "Failed to load archive into builder") return self @@ -3566,8 +3571,10 @@ def _sign_internal( self.close() raise C2paError(f"Error during signing: {e}") - _check_ffi_operation_result(result, - "Error during signing", check=lambda r: r < 0) + _check_ffi_operation_result( + result, + "Error during signing", + check=lambda r: r < 0) # Capture the manifest bytes if available manifest_bytes = b"" @@ -3623,7 +3630,8 @@ def _sign_common( signer=signer, ) elif self._has_context_signer: - manifest_bytes = self._sign_internal(format, source_stream, dest_stream) + manifest_bytes = self._sign_internal( + format, source_stream, dest_stream) else: raise C2paError( "No signer provided. Either pass a" @@ -3792,8 +3800,10 @@ def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: ctypes.byref(result_bytes_ptr) ) - _check_ffi_operation_result(result, - "Failed to format embeddable manifest", check=lambda r: r < 0) + _check_ffi_operation_result( + result, + "Failed to format embeddable manifest", + check=lambda r: r < 0) size = result try: @@ -3924,7 +3934,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: ) _check_ffi_operation_result(signature_ptr, - "Failed to sign data with Ed25519") + "Failed to sign data with Ed25519") try: # Ed25519 signatures are always 64 bytes diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 4fd1c81a..57e0d755 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -67,10 +67,25 @@ def load_test_settings_json(): return settings_data +def parse_native_version(): + """ + Parse the expected native SDK version from 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__))) + version_path = os.path.join(repo_root, 'c2pa-native-version.txt') + with open(version_path, 'r') as f: + raw = f.read().strip() + # Strip the "c2pa-v" prefix to get the bare semantic version. + return raw.split('v', 1)[1] if 'v' in raw else raw + + class TestC2paSdk(unittest.TestCase): def test_sdk_version(self): # This test verifies the native libraries used match the expected version. - self.assertIn("0.85.2", sdk_version()) + self.assertIn(parse_native_version(), sdk_version()) class TestReader(unittest.TestCase):