diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..72742fe0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,230 @@ +name: Release to PyPI + +on: + workflow_dispatch: + inputs: + version_override: + description: "Manual version override (leave empty to use semantic-release)" + required: false + type: string + dry_run: + description: "Dry run (determine version only, do not publish)" + required: false + type: boolean + default: false + +jobs: + determine-version: + name: Determine Version + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: true + - uses: actions/setup-node@v4 + with: + node-version: "26" + - name: Install semantic-release + run: npm install -g semantic-release @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/changelog @semantic-release/exec @semantic-release/git @semantic-release/github conventional-changelog-conventionalcommits + - name: Determine next version + id: version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ -n "${{ inputs.version_override }}" ]; then + echo "version=${{ inputs.version_override }}" >> "$GITHUB_OUTPUT" + echo "Using manual override: ${{ inputs.version_override }}" + else + # Run semantic-release in dry-run to get the next version + VERSION=$(npx semantic-release --dry-run 2>&1 | grep -oP 'The next release version is \K[0-9]+\.[0-9]+\.[0-9]+' || true) + if [ -z "$VERSION" ]; then + echo "No release needed based on commits" + echo "version=" >> "$GITHUB_OUTPUT" + else + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Semantic release determined version: $VERSION" + fi + fi + + test: + name: Run Tests + needs: determine-version + if: needs.determine-version.outputs.version != '' + uses: ./.github/workflows/python-integ.yml + permissions: + id-token: write + contents: read + secrets: inherit + + build: + name: Build Package + needs: [determine-version, test] + if: needs.determine-version.outputs.version != '' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + - run: pip install build + - name: Set version in pyproject.toml + run: sed -i "s/^version = .*/version = \"${{ needs.determine-version.outputs.version }}\"/" pyproject.toml + - name: Verify version + run: | + grep "version = \"${{ needs.determine-version.outputs.version }}\"" pyproject.toml + - run: python -m build + - uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + + publish-testpypi: + name: Publish to TestPyPI + if: ${{ !inputs.dry_run && needs.determine-version.outputs.version != '' }} + needs: [determine-version, build] + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v7 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + + validate-testpypi: + name: Validate TestPyPI Package + needs: [determine-version, publish-testpypi] + if: needs.determine-version.outputs.version != '' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: release-validation + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + - uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: arn:aws:iam::370957321024:role/S3EC-Python-Github-test-role + aws-region: us-west-2 + - name: Wait for TestPyPI availability + run: | + for i in $(seq 1 30); do + if pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ "amazon-s3-encryption-client-python==${{ needs.determine-version.outputs.version }}" 2>/dev/null; then + echo "Package available on TestPyPI" + exit 0 + fi + echo "Waiting for package to appear on TestPyPI ($i/30)..." + sleep 10 + done + echo "Package not found on TestPyPI after 5 minutes" + exit 1 + - name: Run validation + run: python release-validation/validate.py + env: + CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }} + CI_KMS_KEY_ALIAS: ${{ vars.CI_KMS_KEY_ALIAS }} + + publish-pypi: + name: Publish to PyPI + needs: [determine-version, validate-testpypi] + if: needs.determine-version.outputs.version != '' + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v7 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + + validate-pypi: + name: Validate PyPI Package + needs: [determine-version, publish-pypi] + if: needs.determine-version.outputs.version != '' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: release-validation + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + - uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: arn:aws:iam::370957321024:role/S3EC-Python-Github-test-role + aws-region: us-west-2 + - name: Wait for PyPI availability + run: | + for i in $(seq 1 30); do + if pip install "amazon-s3-encryption-client-python==${{ needs.determine-version.outputs.version }}" 2>/dev/null; then + echo "Package available on PyPI" + exit 0 + fi + echo "Waiting for package to appear on PyPI ($i/30)..." + sleep 10 + done + echo "Package not found on PyPI after 5 minutes" + exit 1 + - name: Run validation + run: python release-validation/validate.py + env: + CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }} + CI_KMS_KEY_ALIAS: ${{ vars.CI_KMS_KEY_ALIAS }} + + create-release: + name: Create GitHub Release + if: ${{ !inputs.dry_run && needs.determine-version.outputs.version != '' }} + needs: [determine-version, validate-pypi] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: true + - uses: actions/setup-node@v4 + with: + node-version: "26" + - name: Install semantic-release + run: npm install -g semantic-release @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/changelog @semantic-release/exec @semantic-release/git @semantic-release/github conventional-changelog-conventionalcommits + - name: Create release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ needs.determine-version.outputs.version }}" + if [ -n "${{ inputs.version_override }}" ]; then + # Manual override: commit the version bump and create a GitHub release + sed -i "s/^version = .*/version = \"${VERSION}\"/" pyproject.toml + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add pyproject.toml + git commit -m "chore(release): ${VERSION} [skip ci]" || true + git tag "v${VERSION}" + git push --follow-tags + gh release create "v${VERSION}" \ + --title "v${VERSION}" \ + --generate-notes \ + --draft + else + npx semantic-release + fi diff --git a/.gitignore b/.gitignore index b0b67407..39fc3914 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ smithy-java-core/out .coverage coverage-report/ perf-results/ + +# Sphinx docs build output +docs/_build/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..26768c7d --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/conf.py + +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/.releaserc.cjs b/.releaserc.cjs new file mode 100644 index 00000000..bdd99b04 --- /dev/null +++ b/.releaserc.cjs @@ -0,0 +1,70 @@ +/** + * Semantic Release configuration for Amazon S3 Encryption Client for Python. + * + * Determines the next version from conventional commits, updates pyproject.toml, + * generates release notes, and creates a GitHub release. + */ +module.exports = { + branches: ["main"], + plugins: [ + [ + "@semantic-release/commit-analyzer", + { + preset: "conventionalcommits", + releaseRules: [ + { type: "feat", release: "minor" }, + { type: "fix", release: "patch" }, + { type: "perf", release: "patch" }, + { type: "revert", release: "patch" }, + { breaking: true, release: "major" }, + ], + }, + ], + [ + "@semantic-release/release-notes-generator", + { + preset: "conventionalcommits", + presetConfig: { + types: [ + { type: "feat", section: "Features" }, + { type: "fix", section: "Bug Fixes" }, + { type: "perf", section: "Performance" }, + { type: "revert", section: "Reverts" }, + { type: "docs", section: "Documentation", hidden: false }, + { type: "chore", section: "Maintenance", hidden: false }, + { type: "refactor", section: "Refactoring", hidden: false }, + { type: "test", section: "Tests", hidden: true }, + { type: "ci", section: "CI", hidden: true }, + ], + }, + }, + ], + [ + "@semantic-release/exec", + { + prepareCmd: + 'sed -i "s/^version = .*/version = \\"${nextRelease.version}\\"/" pyproject.toml', + }, + ], + [ + "@semantic-release/changelog", + { + changelogFile: "CHANGELOG.md", + }, + ], + [ + "@semantic-release/git", + { + assets: ["pyproject.toml", "CHANGELOG.md"], + message: + "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", + }, + ], + [ + "@semantic-release/github", + { + draftRelease: true, + }, + ], + ], +}; diff --git a/Makefile b/Makefile index d01d75a3..e788379b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint format test test-unit test-integration test-perf install +.PHONY: lint format format-check test test-unit test-integration test-perf install docs # Default target all: lint test duvet @@ -56,3 +56,12 @@ duvet-report: duvet-view-report-mac: open .duvet/reports/report.html + +# Build docs locally +docs: + uv pip install -e ".[docs]" + uv run sphinx-build -b html docs/ docs/_build/html + @echo "Docs built at docs/_build/html/index.html" + +docs-open: docs + open docs/_build/html/index.html diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..4611623b --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,35 @@ +API Reference +============= + +Client +------ + +.. automodule:: s3_encryption + :members: S3EncryptionClient, S3EncryptionClientConfig + +Materials +--------- + +KMS Keyring +~~~~~~~~~~~ + +.. automodule:: s3_encryption.materials.kms_keyring + :members: + +Keyring Interface +~~~~~~~~~~~~~~~~~ + +.. automodule:: s3_encryption.materials.keyring + :members: + +Materials +~~~~~~~~~ + +.. automodule:: s3_encryption.materials.materials + :members: + +Exceptions +---------- + +.. automodule:: s3_encryption.exceptions + :members: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..584e5145 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,38 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Sphinx configuration for Amazon S3 Encryption Client for Python.""" + +project = "Amazon S3 Encryption Client for Python" +copyright = "Amazon.com, Inc. or its affiliates" +author = "AWS Crypto Tools" + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", +] + +# Napoleon settings for Google-style docstrings +napoleon_google_docstring = True +napoleon_numpy_docstring = False + +# Autodoc settings +autodoc_member_order = "bysource" +autodoc_default_options = { + "members": True, + "undoc-members": False, + "show-inheritance": True, +} + +# Intersphinx mappings +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "boto3": ("https://boto3.amazonaws.com/v1/documentation/api/latest/", None), +} + +# Theme +html_theme = "sphinx_rtd_theme" + +# Exclude patterns +exclude_patterns = ["_build"] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..84dd359a --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,41 @@ +Amazon S3 Encryption Client for Python +======================================= + +The Amazon S3 Encryption Client for Python provides client-side encryption +for objects stored in Amazon S3. It wraps a standard boto3 S3 client and +transparently encrypts objects on upload and decrypts them on download. + +.. toctree:: + :maxdepth: 2 + :caption: Contents + + api + +Getting Started +--------------- + +.. code-block:: python + + import boto3 + from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig + from s3_encryption.materials.kms_keyring import KmsKeyring + + kms_client = boto3.client("kms", region_name="us-west-2") + keyring = KmsKeyring(kms_client, "arn:aws:kms:us-west-2:123456789012:alias/my-key") + + s3_client = boto3.client("s3") + config = S3EncryptionClientConfig(keyring=keyring) + s3ec = S3EncryptionClient(s3_client, config) + + # Encrypt and upload + s3ec.put_object(Bucket="my-bucket", Key="my-object", Body=b"secret data") + + # Download and decrypt + response = s3ec.get_object(Bucket="my-bucket", Key="my-object") + plaintext = response["Body"].read() + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` diff --git a/pyproject.toml b/pyproject.toml index 25318942..abad14d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,10 @@ dev = [ "ruff>=0.15.12", "boto3-stubs~=1.43.6", ] +docs = [ + "sphinx>=7.0,<8", + "sphinx-rtd-theme>=2.0,<3", +] [build-system] requires = ["hatchling"] diff --git a/release-validation/validate.py b/release-validation/validate.py new file mode 100644 index 00000000..c9af1ef3 --- /dev/null +++ b/release-validation/validate.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Post-release validation: install the published package and do a round-trip. + +This script is run after publishing to TestPyPI or PyPI to verify that +the released artifact works correctly for consumers. +""" + +import os +import sys +import uuid + +import boto3 + +from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig +from s3_encryption._utils import _PACKAGE_VERSION +from s3_encryption.materials.kms_keyring import KmsKeyring + +BUCKET = os.environ.get("CI_S3_BUCKET", "s3ec-python-github-test-bucket") +KMS_KEY_ID = os.environ.get( + "CI_KMS_KEY_ALIAS", "arn:aws:kms:us-west-2:370957321024:alias/S3EC-Python-Github-KMS-Key" +) +REGION = "us-west-2" + + +def main(): + print(f"Validating amazon-s3-encryption-client-python v{_PACKAGE_VERSION}") + + kms_client = boto3.client("kms", region_name=REGION) + keyring = KmsKeyring(kms_client, KMS_KEY_ID) + s3_client = boto3.client("s3", region_name=REGION) + config = S3EncryptionClientConfig(keyring=keyring) + s3ec = S3EncryptionClient(s3_client, config) + + key = f"release-validation/{uuid.uuid4()}" + plaintext = b"Release validation round-trip test" + + # Put + print(f" Encrypting and uploading to s3://{BUCKET}/{key}") + s3ec.put_object(Bucket=BUCKET, Key=key, Body=plaintext) + + # Get + print(f" Downloading and decrypting from s3://{BUCKET}/{key}") + response = s3ec.get_object(Bucket=BUCKET, Key=key) + result = response["Body"].read() + + assert result == plaintext, f"Round-trip failed: expected {plaintext!r}, got {result!r}" + + # Cleanup + s3_client.delete_object(Bucket=BUCKET, Key=key) + + print(" Round-trip validation passed!") + print(f" Version: {_PACKAGE_VERSION}") + print(f" User-Agent includes: S3ECPy/{_PACKAGE_VERSION}") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/s3_encryption/__init__.py b/src/s3_encryption/__init__.py index e06ca9e1..ea6f1dc8 100644 --- a/src/s3_encryption/__init__.py +++ b/src/s3_encryption/__init__.py @@ -396,8 +396,8 @@ def put_object(self, **kwargs): The response from the S3 client's put_object method. Raises: - S3EncryptionClientError: Any problem with encryption, including if the Body parameter - has an invalid type. + S3EncryptionClientError: Any problem with encryption, including if + the Body parameter has an invalid type. """ # Extract EncryptionContext if provided (not a standard S3 parameter) encryption_context = kwargs.pop("EncryptionContext", None)