diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 00000000..d3c3b427 --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,52 @@ +name: Build and publish docs + +on: + release: + types: [published] + + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: 'github-pages' + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + filter: tree:0 + fetch-depth: 0 + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: "pip" + + - name: Install dev deps + run: python3 -m pip install -r requirements-dev.txt + + - name: Build docs + run: python3 scripts/generate_api_docs.py + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'api-docs/_build/html/' + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1f67fec6..c612bf1a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,12 @@ wheels/ .installed.cfg *.egg +# Sphinx docs +api-docs/api +api-docs/_build +api-docs/_static +api-docs/_preprocessed + # PyInstaller # Usually these files are written by a Python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. diff --git a/Makefile b/Makefile index 02105bff..7802a319 100644 --- a/Makefile +++ b/Makefile @@ -102,4 +102,8 @@ format: # Downloads the required native artifacts for the specified version download-native-artifacts: - python3 scripts/download_artifacts.py $(C2PA_VERSION) \ No newline at end of file + python3 scripts/download_artifacts.py $(C2PA_VERSION) + +# Build API documentation with Sphinx +docs: + python3 scripts/generate_api_docs.py \ No newline at end of file diff --git a/README.md b/README.md index 1fd37e03..98c640de 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/m - `examples/sign.py` shows how to sign and verify an asset with a C2PA manifest. - `examples/training.py` demonstrates how to add a "Do Not Train" assertion to an asset and verify it. +## 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). + ## Contributing Contributions are welcome! For more information, see [Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md). diff --git a/api-docs/conf.py b/api-docs/conf.py new file mode 100644 index 00000000..bc1afbc7 --- /dev/null +++ b/api-docs/conf.py @@ -0,0 +1,64 @@ +import os +from pathlib import Path + +# -- Project information ----------------------------------------------------- + +project = "c2pa-python" +author = "Content Authenticity Initiative (CAI)" + +# -- General configuration --------------------------------------------------- + +extensions = [ + "myst_parser", + "autoapi.extension", + "sphinx.ext.napoleon", +] + +myst_enable_extensions = [ + "colon_fence", + "deflist", + "fieldlist", + "strikethrough", + "tasklist", + "attrs_block", + "attrs_inline", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# -- AutoAPI configuration --------------------------------------------------- + +project_root = Path(__file__).resolve().parents[1] +autoapi_type = "python" +# Allow overriding the source path used by AutoAPI (for preprocessing) +autoapi_dirs = [ + os.environ.get( + "C2PA_DOCS_SRC", + str(project_root / "src" / "c2pa"), + ) +] +autoapi_root = "api" +autoapi_keep_files = True +autoapi_add_toctree_entry = True +autoapi_options = [ + "members", + "undoc-members", + "show-inheritance", + "show-module-summary", + "imported-members", +] + +# -- Options for HTML output ------------------------------------------------- + +html_theme = "furo" +html_static_path = ["_static"] + +# Avoid executing package imports during docs build +autodoc_typehints = "description" + +# Napoleon (Google/Numpy docstring support) +napoleon_google_docstring = True +napoleon_numpy_docstring = False + + diff --git a/api-docs/index.rst b/api-docs/index.rst new file mode 100644 index 00000000..b44fc940 --- /dev/null +++ b/api-docs/index.rst @@ -0,0 +1,12 @@ +.. c2pa-python documentation master file + +Welcome to c2pa-python's documentation! +======================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + api/index + + diff --git a/docs/project-contributions.md b/docs/project-contributions.md index fbe472d8..c3affcb4 100644 --- a/docs/project-contributions.md +++ b/docs/project-contributions.md @@ -127,3 +127,28 @@ To rebuild and test, enter these commands: make build-python make test ``` + +## API reference documentation + +We use Sphinx autodoc to generate API docs. + +Install development dependencies: + +``` +cd c2pa-python +python3 -m pip install -r requirements-dev.txt +``` + +Build docs by entering `make -C docs` or: + +``` +python3 scripts/generate_api_docs.py +``` + +View the output by loading `api-docs/build/html/index.html` in a web browser. + +This uses `sphinx-autoapi` to parse `src/c2pa` directly, avoiding imports of native libs. +- Entry script: `scripts/generate_api_docs.py` +- Config: `api-docs/conf.py`; index: `api-docs/index.rst` + +Sphinx config is in `api-docs/conf.py`, which uses `index.rst`. diff --git a/requirements-dev.txt b/requirements-dev.txt index 6533001f..26e511c1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -16,4 +16,10 @@ autopep8==2.0.4 # For automatic code formatting flake8==7.3.0 # Test dependencies (for callback signers) -cryptography==45.0.6 \ No newline at end of file +cryptography==45.0.6 + +# Documentation +Sphinx>=7.3.0 +sphinx-autoapi>=3.0.0 +myst-parser>=2.0.0 +furo>=2024.0.0 \ No newline at end of file diff --git a/scripts/generate_api_docs.py b/scripts/generate_api_docs.py new file mode 100644 index 00000000..df6ce5b2 --- /dev/null +++ b/scripts/generate_api_docs.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Generate API documentation using Sphinx + AutoAPI. + +This script builds HTML docs into docs/_build/html. +It avoids importing the package by relying on sphinx-autoapi +to parse source files directly. +""" + +import shutil +import os +import sys +from pathlib import Path +import importlib + + +def ensure_tools_available() -> None: + try: + importlib.import_module("sphinx") + importlib.import_module("autoapi") + importlib.import_module("myst_parser") + except Exception as exc: + root = Path(__file__).resolve().parents[1] + req = root / "requirements-dev.txt" + print( + "Missing documentation dependencies. " + f"Install with: python3 -m pip install -r {req}", + file=sys.stderr, + ) + raise SystemExit(1) from exc + + +def build_docs() -> None: + root = Path(__file__).resolve().parents[1] + docs_dir = root / "api-docs" + build_dir = docs_dir / "_build" / "html" + api_dir = docs_dir / "api" + + # Preprocess sources: convert Markdown code fences in docstrings to reST + src_pkg_dir = root / "src" / "c2pa" + pre_dir = docs_dir / "_preprocessed" + pre_pkg_dir = pre_dir / "c2pa" + if pre_dir.exists(): + shutil.rmtree(pre_dir) + pre_pkg_dir.mkdir(parents=True, exist_ok=True) + + def convert_fences_to_rst(text: str) -> str: + lines = text.splitlines() + out: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + stripped = line.lstrip() + indent = line[: len(line) - len(stripped)] + if stripped.startswith("```"): + fence = stripped + lang = fence[3:].strip() or "text" + # Start directive + out.append(f"{indent}.. code-block:: {lang}") + out.append("") + i += 1 + # Emit indented code until closing fence + while i < len(lines): + l2 = lines[i] + if l2.lstrip().startswith("```"): + i += 1 + break + out.append(f"{indent} {l2}") + i += 1 + continue + out.append(line) + i += 1 + return "\n".join(out) + ("\n" if text.endswith("\n") else "") + + for src_path in src_pkg_dir.rglob("*.py"): + rel = src_path.relative_to(src_pkg_dir) + dest = pre_pkg_dir / rel + dest.parent.mkdir(parents=True, exist_ok=True) + content = src_path.read_text(encoding="utf-8") + dest.write_text(convert_fences_to_rst(content), encoding="utf-8") + + # Point AutoAPI to preprocessed sources + os.environ["C2PA_DOCS_SRC"] = str(pre_pkg_dir) + + # Clean AutoAPI output to avoid stale pages + if api_dir.exists(): + shutil.rmtree(api_dir) + + build_dir.mkdir(parents=True, exist_ok=True) + + try: + sphinx_build_mod = importlib.import_module("sphinx.cmd.build") + sphinx_main = getattr(sphinx_build_mod, "main") + code = sphinx_main([ + "-b", + "html", + str(docs_dir), + str(build_dir), + ]) + if code != 0: + raise SystemExit(code) + except Exception: + # Fallback to subprocess if needed + import subprocess + + cmd = [ + sys.executable, + "-m", + "sphinx", + "-b", + "html", + str(docs_dir), + str(build_dir), + ] + subprocess.run(cmd, check=True) + + print(f"API docs generated at: {build_dir}") + + +if __name__ == "__main__": + ensure_tools_available() + build_docs() + + diff --git a/src/c2pa/build.py b/src/c2pa/build.py index 96c4c245..29076e4f 100644 --- a/src/c2pa/build.py +++ b/src/c2pa/build.py @@ -13,7 +13,7 @@ import os import sys -import requests +import requests # type: ignore from pathlib import Path import zipfile import io @@ -53,8 +53,7 @@ def download_artifact(url: str, platform_name: str) -> None: # Extract all files to the platform directory zip_ref.extractall(platform_dir) - print(f"Successfully downloaded and extracted artifacts for { - platform_name}") + print(f"Successfully downloaded and extracted artifacts for {platform_name}") def download_artifacts() -> None: @@ -95,7 +94,7 @@ def download_artifacts() -> None: def inject_version(): """Inject the version from pyproject.toml into src/c2pa/__init__.py as __version__.""" - import toml + import toml # type: ignore pyproject_path = os.path.abspath( os.path.join( os.path.dirname(__file__), diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 7d920e61..3801a0fc 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -706,24 +706,13 @@ def _get_mime_type_from_path(path: Union[str, Path]) -> str: def read_ingredient_file( path: Union[str, Path], data_dir: Union[str, Path]) -> str: - """Read a file as C2PA ingredient. + """Read a file as C2PA ingredient (deprecated). This creates the JSON string that would be used as the ingredient JSON. .. deprecated:: 0.11.0 This function is deprecated and will be removed in a future version. - Please use the Reader class for reading C2PA metadata instead. - Example: - ```python - with Reader(path) as reader: - manifest_json = reader.json() - ``` - - To add ingredients to a manifest, please use the Builder class. - Example: - ``` - with open(ingredient_file_path, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/jpeg", f) - ``` + To read C2PA metadata, use the :class:`c2pa.c2pa.Reader` class. + To add ingredients to a manifest, use :meth:`c2pa.c2pa.Builder.add_ingredient`. Args: path: Path to the file to read @@ -766,16 +755,11 @@ def read_ingredient_file( def read_file(path: Union[str, Path], data_dir: Union[str, Path]) -> str: - """Read a C2PA manifest from a file. + """Read a C2PA manifest from a file (deprecated). .. deprecated:: 0.10.0 This function is deprecated and will be removed in a future version. - Please use the Reader class for reading C2PA metadata instead. - Example: - ```python - with Reader(path) as reader: - manifest_json = reader.json() - ``` + To read C2PA metadata, use the :class:`c2pa.c2pa.Reader` class. Args: path: Path to the file to read @@ -845,7 +829,7 @@ def sign_file( signer_or_info: Union[C2paSignerInfo, 'Signer'], return_manifest_as_bytes: bool = False ) -> Union[str, bytes]: - """Sign a file with a C2PA manifest. + """Sign a file with a C2PA manifest (deprecated). For now, this function is left here to provide a backwards-compatible API. .. deprecated:: 0.13.0 @@ -1242,7 +1226,15 @@ def initialized(self) -> bool: class Reader: - """High-level wrapper for C2PA Reader operations.""" + """High-level wrapper for C2PA Reader operations. + + Example: + ``` + with Reader("image/jpeg", output) as reader: + manifest_json = reader.json() + ``` + Where `output` is either an in-memory stream or an opened file. + """ # Supported mimetypes cache _supported_mime_types_cache = None @@ -2309,6 +2301,12 @@ def add_ingredient(self, ingredient_json: str, format: str, source: Any): C2paError: If there was an error adding the ingredient C2paError.Encoding: If the ingredient JSON contains invalid UTF-8 characters + + Example: + ``` + with open(ingredient_file_path, 'rb') as a_file: + builder.add_ingredient(ingredient_json, "image/jpeg", a_file) + ``` """ return self.add_ingredient_from_stream(ingredient_json, format, source) @@ -2366,7 +2364,7 @@ def add_ingredient_from_file_path( ingredient_json: str, format: str, filepath: Union[str, Path]): - """Add an ingredient from a file path to the builder. + """Add an ingredient from a file path to the builder (deprecated). This is a legacy method. .. deprecated:: 0.13.0 @@ -2635,7 +2633,7 @@ def create_signer( certs: str, tsa_url: Optional[str] = None ) -> Signer: - """Create a signer from a callback function. + """Create a signer from a callback function (deprecated). .. deprecated:: 0.11.0 This function is deprecated and will be removed in a future version. @@ -2670,7 +2668,7 @@ def create_signer( def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer: - """Create a signer from signer information. + """Create a signer from signer information (deprecated). .. deprecated:: 0.11.0 This function is deprecated and will be removed in a future version.