diff --git a/.devcontainer/.containerignore b/.devcontainer/.containerignore new file mode 100644 index 000000000..28cf290ab --- /dev/null +++ b/.devcontainer/.containerignore @@ -0,0 +1,30 @@ +**/.git +**/.gitignore +**/.vscode +**/.idea +**/.DS_Store +**/.pytest_cache +**/.mypy_cache +**/.ruff_cache +**/__pycache__ +**/*.pyc +**/*.pyo +**/*.pyd +**/.Python +**/pip-log.txt +**/pip-delete-this-directory.txt +**/.venv +**/venv +**/ENV +**/env +**/.coverage +**/.coverage.* +**/htmlcov +**/coverage.xml +**/*.cover +**/*.log +**/.hypothesis +**/dist +**/build +**/*.egg-info +**/node_modules diff --git a/.devcontainer/Containerfile b/.devcontainer/Containerfile new file mode 100644 index 000000000..d6a4f4f5d --- /dev/null +++ b/.devcontainer/Containerfile @@ -0,0 +1,27 @@ +FROM python:3.14-slim@sha256:7a500125bc50693f2214e842a621440a1b1b9cbb2188f74ab045d29ed2ea5856 + +ARG USERNAME=vscode +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends \ + git \ + curl \ + wget \ + build-essential \ + sudo \ + libffi-dev \ + && groupadd --gid $USER_GID $USERNAME \ + && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \ + && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +USER $USERNAME + +ENV PATH="/home/${USERNAME}/.cargo/bin:/home/${USERNAME}/.local/bin:${PATH}" + +RUN curl -LsSf https://astral.sh/uv/install.sh | sh \ + && uv tool install hatch diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..11a53d0b3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,35 @@ +{ + "name": "Pact Python Development", + "build": { + "dockerfile": "Containerfile", + "context": ".." + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "charliermarsh.ruff" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python3", + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": ["tests/"], + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } + } + } + }, + "containerEnv": { + "PYTHONUNBUFFERED": "1" + }, + "postCreateCommand": "git submodule update --init && hatch env create", + "remoteUser": "vscode", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached", + "workspaceFolder": "/workspace" +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..e0c8b5cf0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,pyi}] +indent_size = 4 + +[Makefile] +indent_size = 4 +indent_style = tab + +[*.md] +indent_size = 4 diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..86867d10b --- /dev/null +++ b/.envrc @@ -0,0 +1,11 @@ +# shellcheck shell=bash + +# If `.env` exists, load it +# This is useful to store secrets and making them available to the shell +# without risking exposing them. +dotenv_if_exists + +# For this to work, you will need to add: +# https://github.com/JP-Ellis/dotfiles/blob/c6b01b8cd633189920ded05b1201e4c32e9e597f/direnv/direnvrc#L26-L39 +# to your `direnvrc` file (usually located at `~/.config/direnv/direnvrc`) +layout hatch diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..36a31aee0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,125 @@ +--- +name: 🐛 Bug Report +description: Submit a bug report to help us improve + +labels: + - bug + - triage + +body: + - type: markdown + attributes: + value: | + ## Please help us help you! + + Before filing your issue, ask yourself: + + - Has this bug already been reported? + - Is this clearly a Pact Python bug? + - Do I have basic ideas about where it goes wrong? + - Could it be because of something on my end? + + **The GitHub issue tracker is not a support forum**. If you are not sure whether it could be on your end or within Pact Python, ask on [Slack](https://slack.pact.io). + + Make the bug obvious. Ideally, we should be able to understand it without running any code. + + Bugs are fixed faster if you include: + - A reproduction repository to replicate the issue + - Pact files + - Logs + + - type: checkboxes + attributes: + label: Have you read the Contributing Guidelines on issues? + options: + - label: I have read the [Contributing Guidelines on issues](https://github.com/pact-foundation/pact-python/blob/main/CONTRIBUTING.md#issues). + required: true + + - type: checkboxes + attributes: + label: Prerequisites + description: Please check the following items before creating a issue. This way we know you've done + these steps first. + options: + - label: I'm using the latest version of `pact-python`. + required: true + - label: I have read the console error message carefully (if applicable). + + - type: textarea + attributes: + label: Description + description: A clear and concise description of what the bug is. + validations: + required: true + + - type: input + attributes: + label: Reproducible demo + description: | + Paste the link to an example repo if possible, and exact instructions to reproduce the issue. + + > **What happens if you skip this step?** Someone will read your bug report, and maybe will be able to help you, but if we fail to reproduce the issue, we might not be able to fix it. + + Please remember that: + + - Issues without reproducible demos have a very low priority. + - The person fixing the bug would have to do that anyway. Please be respectful of their time. + - You might figure out the issues yourself as you work on extracting it. + + Thanks for helping us help you! + + - type: textarea + attributes: + label: Steps to reproduce + description: Write down the steps to reproduce the bug. You should start with a fresh installation, + or your git repository linked above. + placeholder: | + 1. Step 1... + 2. Step 2... + 3. Step 3... + validations: + required: true + + - type: textarea + attributes: + label: Expected behavior + description: How did you expect your project to behave? It's fine if you're not sure your understanding + is correct. Write down what you thought would happen. + placeholder: Write what you thought would happen. + validations: + required: true + + - type: textarea + attributes: + label: Actual behavior + description: | + Did something go wrong? Is something broken, or not behaving as you expected? Describe this section in detail. Don't only say "it doesn't work"! Please submit exhaustive and complete log messages, not just the final error message. + + > Please read error messages carefully: it often tells you exactly what you are doing wrong. + + If the logs are too long, you can paste them in a [gist](https://gist.github.com/) and link it here. + placeholder: | + Write what happened. Add full console log messages. + validations: + required: true + + - type: textarea + attributes: + label: Your environment + description: Include as many relevant details about the environment you experienced the bug in. + value: | + - Public source code: + - Is this a consumer or provider issue? Do you have information about the other side? + - Pact Python version used: `pip list | grep pact` + - Information about your Pact broker (version, hosted where, pactflow, ...) + - Operating system and version (e.g. Ubuntu 20.04.2 LTS, macOS Ventura): + + - type: checkboxes + attributes: + label: Self-service + description: | + If you feel like you could contribute to this issue, please check the box below. This would tell us and other people looking for contributions that someone's working on it. + + If you do check this box, please send a pull request. If circumstances change and you can't work on it anymore, let us know and we can re-assign it. + options: + - label: I'd be willing to fix this bug myself. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..ae16a8037 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,15 @@ +--- +blank_issues_enabled: true + +contact_links: + - name: ❓ Simple question - Slack chat + url: https://slack.pact.io + about: If you have a simple question, or want to discuss a feature request, join our Slack chat. + - name: ❓ Simple question - Stack Overflow + url: https://stackoverflow.com/questions/tagged/pact + about: The GitHub issue tracker is not for technical support. Please use Stack Overflow, and ask the + community for help. + - name: ❓ Advanced question - GitHub Discussions + url: https://github.com/pact-foundation/pact-python/discussions + about: Use GitHub Discussions for advanced and unanswered questions only, requiring a maintainer's + answer. Make sure the question wasn't already asked. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 000000000..89acb87ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,54 @@ +--- +name: 💡 Feature design / RFC +description: Submit a detailed feature request with a concrete proposal + +labels: + - feature + - triage + +body: + - type: markdown + attributes: + value: | + Important things: + + - We expect the feature request to be detailed. + - The request does not have to be perfect, we'll discuss it and fix it if needed. + + - type: checkboxes + attributes: + label: Have you read the Contributing Guidelines on issues? + options: + - label: I have read the [Contributing Guidelines on issues](https://github.com/pact-foundation/pact-python/blob/main/CONTRIBUTING.md#issues). + required: true + + - type: textarea + attributes: + label: Description + description: A clear and concise description of what the feature is. + validations: + required: true + + - type: textarea + attributes: + label: Motivation + description: Please outline the motivation for the proposal and why it should be implemented. Has + this been requested by a lot of users? + validations: + required: true + + - type: textarea + attributes: + label: Have you tried building it? + description: | + Please explain how you tried to build the feature by yourself, and how successful it was. If you haven't tried, that's alright. + + - type: checkboxes + attributes: + label: Self-service + description: | + If you answered the question above as "no" because you feel like you could contribute directly to our repo, please check the box below. This would tell us and other people looking for contributions that someone's working on it. + + If you do check this box, please send a pull request. If circumstances change and you can't work on it anymore, let us know and we can re-assign it. + options: + - label: I'd be willing to contribute this feature to Pact Python myself. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..f43b17a19 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,37 @@ + + +## :airplane: Pre-flight checklist + +- [ ] I have read the [Contributing Guidelines on pull requests](https://github.com/pact-foundation/pact-python/blob/main/CONTRIBUTING.md#pull-requests). +- [ ] **If this is a code change**: I have written unit tests and/or added dogfooding pages to fully verify the new behavior. +- [ ] **If this is a new API or substantial change**: the PR has an accompanying issue (closes #0000) and the maintainers have approved on my working plan. + +## :memo: Summary + + + +## :rotating_light: Breaking Changes + + + +## :fire: Motivation + + + +## :hammer: Test Plan + + + +## :link: Related issues/PRs + + diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 000000000..f9e14620d --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,5 @@ +--- +coverage: + precision: 0 + round: down + range: 60...85 diff --git a/.github/instructions/pact-cli.instructions.md b/.github/instructions/pact-cli.instructions.md new file mode 100644 index 000000000..e1b08ca03 --- /dev/null +++ b/.github/instructions/pact-cli.instructions.md @@ -0,0 +1,11 @@ +--- +description: "Pact CLI" +applyTo: "/pact-python-cli/**" +--- + +# Pact CLI + +- This directory contains the source for the `pact-python-cli` package, which provides a thin wrapper around the Pact CLI binaries. +- It allows users to install the Pact CLI tools via PyPI and use them in Python projects without requiring separate installation steps. +- The package includes executable wrappers for all major Pact CLI tools (`pact`, `pact-broker`, `pact-message`, `pact-mock-service`, `pact-provider-verifier`, etc.). +- By default, it uses bundled binaries, but can fall back to system-installed Pact CLI tools when `PACT_USE_SYSTEM_BINS` environment variable is set to `TRUE` or `YES`. diff --git a/.github/instructions/pact-ffi.instructions.md b/.github/instructions/pact-ffi.instructions.md new file mode 100644 index 000000000..da62203b6 --- /dev/null +++ b/.github/instructions/pact-ffi.instructions.md @@ -0,0 +1,15 @@ +--- +description: "Pact FFI" +applyTo: "/pact-python-ffi/**" +--- + +# Pact FFI + +- This directory contains the source for the `pact-python-ffi` package, which provides Python bindings to the Pact FFI library. +- This library only exposes low-level FFI bindings and is not intended for direct use by end users. All user-facing functionality should be provided through the higher-level `pact` package. +- Code in this package should focus exclusively on: + - Providing automatic memory management for FFI objects (implementing `__del__` methods to drop/free objects as needed) + - Converting between Python types and FFI types (input parameter casting and return value conversion) + - Handling errors returned from the FFI and converting them into appropriate Python exceptions + - Wrapping low-level C structs and handles in Python classes with proper lifecycle management +- Avoid implementing high-level business logic or convenience methods - these belong in the main `pact` package. diff --git a/.github/instructions/pact-v2.instructions.md b/.github/instructions/pact-v2.instructions.md new file mode 100644 index 000000000..cac47aea6 --- /dev/null +++ b/.github/instructions/pact-v2.instructions.md @@ -0,0 +1,14 @@ +--- +description: "Pact V2" +applyTo: "/src/pact/v2/**" +--- + +# Pact V2 Legacy Code + +- These files provide backwards compatibility with version 2 of Pact Python. +- They are in maintenance mode with only critical bug fixes being applied - no new features should be added. +- When making changes: + - Preserve existing APIs and behavior to maintain backwards compatibility + - Prioritize minimal, targeted fixes over architectural improvements + - Ensure changes do not break existing user code +- New development should focus on the main V3+ codebase in `/src/pact/` instead. diff --git a/.github/instructions/pact.instructions.md b/.github/instructions/pact.instructions.md new file mode 100644 index 000000000..9a6a35e23 --- /dev/null +++ b/.github/instructions/pact.instructions.md @@ -0,0 +1,21 @@ +--- +description: "Pact Core Library" +applyTo: "/src/pact/**" +--- + +# Pact Core Library + +- The code in `src/pact/` forms the core Pact library that provides the main user-facing APIs. +- This is the primary codebase for Pact Python functionality. +- Key modules include: + - `pact.py` - Main Pact class for consumer testing + - `verifier.py` - Provider verification functionality + - `match/` - Matching rules and matchers + - `generate/` - Value generators + - `interaction/` - Interaction building blocks + +## V2 Legacy Code + +- Files in `v2/` subdirectories implement the legacy version of Pact Python +- This version is in maintenance mode with only critical bug fixes being applied +- New features and active development should focus on the main codebase diff --git a/.github/instructions/python-tests.instructions.md b/.github/instructions/python-tests.instructions.md new file mode 100644 index 000000000..e5461847e --- /dev/null +++ b/.github/instructions/python-tests.instructions.md @@ -0,0 +1,31 @@ +--- +description: "Python testing conventions and guidelines" +applyTo: "**/tests/**/*.py" +--- + +# Python Testing Conventions + +- Use `pytest` as the testing framework, with all tests located in `tests/` directories and files prefixed with `test_`. +- Prefer descriptive function names that clearly indicate the test's purpose. Include docstrings only when additional context is needed beyond the function name. +- Use `@pytest.mark.parametrize` to cover multiple scenarios without code duplication: + + ```python + @pytest.mark.parametrize( + ("param1", "param2", "expected"), + [ + pytest.param(v1, x1, r1, id="description1"), + pytest.param(v2, x2, r2, id="description2"), + ... + ] + ) + def test_function(param1: Type1, param2: Type2, expected: ReturnType) -> None: + ... + ``` + +- Ensure test coverage for: + - Critical application paths and core functionality + - Common edge cases (empty inputs, invalid data types, boundary conditions) + - Error conditions and exception handling +- Include comments explaining complex test logic or edge case rationale. +- Minimize mocking and prefer testing with real data and dependencies when practical. Mock only external services or components that are unreliable or expensive to test against. +- Use pytest fixtures for common test setup and shared data. diff --git a/.github/instructions/python.instructions.md b/.github/instructions/python.instructions.md new file mode 100644 index 000000000..394240630 --- /dev/null +++ b/.github/instructions/python.instructions.md @@ -0,0 +1,52 @@ +--- +description: "Python coding conventions and guidelines" +applyTo: "**/*.py" +--- + +# Python Coding Conventions + +## Documentation + +- MkDocs-Material is used for documentation generation, allowing for Markdown formatting in docstrings. +- All functions must have Google-style, Markdown-compatible docstrings with proper formatting (note whitespace and indentation as shown): + + ```python + def function_name(param1: Type1, param2: Type2) -> ReturnType: + """ + Brief description of the function. + + Optional detailed description of the function. + + Args: + param1: + Description of param1. + + param2: + Description of param2. + + Returns: + Description of the return value. + """ + ``` + +- References to other functions, classes, or modules must be linked, using the fully qualified Python path: + + ```markdown + A link to a [`ClassName.method`][pact.module.ClassName.method] or a + [`function_name`][pact.module.function_name]. + ``` + +## General Instructions + +- Always prioritize readability and clarity. +- All functions must use type annotations for parameters and return types. Prefer generic types (e.g., `Iterable[str]`, `Mapping[str, int]`) over concrete types (`list[str]`, `dict[str, int]`) for better flexibility and reusability. +- Write code with good maintainability practices, including comments on why certain design decisions were made. +- Handle edge cases and write clear exception handling. +- Write concise, efficient, and idiomatic code that is also easily understandable. +- When performing validations, use early returns to reduce nesting and improve readability. + - Prefer built-in exceptions (such as `ValueError` for invalid values, `TypeError` for incorrect types, etc.) for standard Python errors. + - For Pact-specific issues, define and use custom exceptions to provide clear and meaningful error handling. These must all inherit from the `PactError` base class, and may inherit from other exceptions as appropriate. + +## Code Style and Linting + +- Use `ruff` for linting and formatting, preferring automatic fixes where possible. diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 000000000..d1bd66533 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,20 @@ +--- +- name: area:cli + description: Relating to the CLI + color: C2E0C6 + +- name: area:core + description: Relating to the core Pact Python library + color: C2E0C6 + +- name: area:examples + description: Relating to the examples + color: C2E0C6 + +- name: area:ffi + description: Relating to the FFI + color: C2E0C6 + +- name: area:v2 + description: Relating to v2 code + color: C2E0C6 diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..2bc5c8ce2 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:best-practices"], + "pre-commit": { + "enabled": true + }, + "git-submodules": { + "enabled": true + }, + "prHourlyLimit": 0, + "prConcurrentLimit": 0, + "automerge": true, + "packageRules": [ + { + "groupName": "Ruff", + "matchPackageNames": ["astral-sh/ruff-pre-commit", "ruff"] + }, + { + "matchPackageNames": ["taiki-e/install-action"], + "schedule": "before 4am on monday" + } + ] +} diff --git a/.github/semantic.yml b/.github/semantic.yml index af6bf3c91..0c884106b 100644 --- a/.github/semantic.yml +++ b/.github/semantic.yml @@ -1,4 +1,3 @@ +--- titleAndCommits: true allowMergeCommits: true - - diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml deleted file mode 100644 index 6624e484f..000000000 --- a/.github/workflows/build_and_test.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Build and Test - -on: [push, pull_request] - -jobs: - build: - name: Python ${{ matrix.python-version }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - - strategy: - # When set to true, GitHub cancels - # all in-progress jobs if any matrix job fails. - fail-fast: false - - matrix: - python-version: - - '3.7' - - '3.8' - - '3.9' - - '3.10' - - '3.11' - os: [ ubuntu-latest ] - - # These versions are no longer supported by Python team, and may - # eventually be dropped from GitHub Actions. - include: - - python-version: '3.6' - os: ubuntu-20.04 - - steps: - - name: Check out code - uses: actions/checkout@v2 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements_dev.txt - - - name: Lint with flake8, pydocstyle - run: | - flake8 - pydocstyle pact - - - name: Test with pytest - run: tox -e test - - - name: Test examples - run: make examples diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..41114fd5e --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,74 @@ +--- +name: docs + +on: + push: + branches: + - main + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - ready_for_review + +env: + STABLE_PYTHON_VERSION: '3.10' + FORCE_COLOR: '1' + HATCH_VERBOSE: '1' + +jobs: + build: + name: Build docs + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + cache-dependency-glob: | + **/pyproject.toml + **/uv.lock + + - name: Install Python + run: uv python install ${{ env.STABLE_PYTHON_VERSION }} + + - name: Install Hatch + run: uv tool install hatch + + - name: Build docs + run: | + hatch run mkdocs build --strict + + - name: Upload artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 + with: + path: site + + publish: + name: Publish docs + if: github.ref == 'refs/heads/main' + + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml new file mode 100644 index 000000000..92d88b083 --- /dev/null +++ b/.github/workflows/labels.yml @@ -0,0 +1,34 @@ +--- +name: Labels + +on: + # For downstream repos, we want to run this on a schedule + # so that updates propagate automatically. Weekly is probably + # enough. + schedule: + - cron: 20 0 * * 0 + push: + branches: + - main + paths: + - .github/labels.yml + +permissions: + issues: write + +jobs: + sync-labels: + name: Synchronise labels + + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Synchronize labels + uses: EndBug/label-sync@52074158190acb45f3077f9099fea818aa43f97a # v2.3.3 + with: + config-file: |- + https://raw.githubusercontent.com/pact-foundation/.github/refs/heads/master/.github/labels.yml + .github/labels.yml diff --git a/.github/workflows/package_and_push_to_pypi.yml b/.github/workflows/package_and_push_to_pypi.yml deleted file mode 100644 index 485fdc16a..000000000 --- a/.github/workflows/package_and_push_to_pypi.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Upload Python Package - -on: - release: - types: [created] - -jobs: - deploy: - environment: "Upload Python Package" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist - twine upload dist/* diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 000000000..9f3a77ac5 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,281 @@ +--- +# Release workflow for pact-python-cli (CLI wrapper package). +# +# This package tracks the upstream pact-foundation/pact-standalone project and +# versions itself as `{upstream_version}.{N}` (e.g. `2.4.0.0`). The release +# lifecycle runs in three stages: +# +# Stage 1: Prepare (trigger: push to main) +# The `prepare` job runs `scripts/release.py prepare cli`, which fetches the +# latest `v*` release from pact-foundation/pact-standalone, computes the next +# wrapper version, updates pyproject.toml and CHANGELOG.md, and force-pushes +# those changes to the fixed branch `release/pact-python-cli`. It then +# creates (or updates the title and body of) the release PR targeting main. +# +# Stage 2: Tag (trigger: release PR merged → `closed` event, merged == true) +# When the release PR on `release/pact-python-cli` is merged, the `tag` job +# runs `scripts/release.py tag cli`, which reads the version from +# pyproject.toml and pushes a git tag of the form `pact-python-cli/X.Y.Z.N`. +# +# Stage 3: Publish (trigger: tag push matching `pact-python-cli/*`) +# The `build-sdist`, `build-wheels`, and `publish` jobs build the full wheel +# matrix (macOS/Linux/Windows, multiple architectures), create a GitHub +# release with the changelog, and publish all artifacts to PyPI. +# +# Additional: `build-sdist` and `build-wheels` also run on open/updated release +# PRs (not closed) to verify builds before merging. +# +# The `closed` PR type is listed in the trigger so that Stage 2 fires on merge. +# All jobs except `tag` guard against closed-but-not-merged events with +# explicit `if` conditions. +name: release cli + +on: + push: + branches: + - main + tags: + - pact-python-cli/* + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - closed + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }} + +env: + STABLE_PYTHON_VERSION: '310' + HATCH_VERBOSE: '1' + FORCE_COLOR: '1' + CIBW_BUILD_FRONTEND: build + +jobs: + complete: + name: Release CLI completion check + if: always() + runs-on: ubuntu-latest + needs: + - prepare + - tag + - build-sdist + - build-wheels + - publish + steps: + - name: Failed + run: exit 1 + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + + prepare: + name: Update CLI release PR + if: >- + github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: + name: release-pr + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Need full history for git-cliff to determine the next version and + # generate the changelog + fetch-depth: 0 + token: ${{ secrets.GH_TOKEN }} + + - name: Install git-cliff and typos + uses: taiki-e/install-action@7be9fd86bd1707236395105d6e9329dd1511a7e1 # v2.79.0 + with: + tool: git-cliff,typos + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Install Python + # Bump to 3.11 to access tomllib + run: uv python install 311 + + - name: Update release PR + run: uv run python scripts/release.py prepare cli + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + + tag: + name: Create CLI release tag + if: >- + github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref + == 'release/pact-python-cli' + runs-on: ubuntu-latest + environment: + name: release-pr + steps: + - name: Checkout main + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + token: ${{ secrets.GH_TOKEN }} + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Install Python + # Bump to 3.11 to access tomllib + run: uv python install 311 + + - name: Create and push release tag + run: uv run python scripts/release.py tag cli + env: + # Require GH_TOKEN (PAT) instead of GITHUB_TOKEN so that the PR can + # itself trigger workflows + GH_TOKEN: ${{ secrets.GH_TOKEN }} + + build-sdist: + name: Build CLI source distribution + # Prepare the wheels for upload, and also verify that the build works on + # release PRs before merging. + if: >- + ( + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/pact-python-cli/') + ) || ( + github.event_name == 'pull_request' && + github.event.action != 'closed' && + github.event.pull_request.head.ref == 'release/pact-python-cli' + ) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Install Python + run: uv python install ${{ env.STABLE_PYTHON_VERSION }} + + - name: Install hatch + run: uv tool install hatch + + - name: Build source distribution + working-directory: pact-python-cli + run: hatch build --target sdist + + - name: Upload sdist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-sdist + path: pact-python-cli/dist/*.tar* + if-no-files-found: error + compression-level: 0 + + build-wheels: + name: Build CLI wheel (release) on ${{ matrix.os }} + # Prepare the wheels for upload, and also verify that the build works on + # release PRs before merging. + if: >- + ( + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/pact-python-cli/') + ) || ( + github.event_name == 'pull_request' && + github.event.action != 'closed' && + github.event.pull_request.head.ref == 'release/pact-python-cli' + ) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - macos-15-intel + - macos-latest + - ubuntu-24.04-arm + - ubuntu-latest + - windows-11-arm + - windows-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build wheels + uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1 + with: + package-dir: pact-python-cli + env: + # Only target the oldest supported Python version, as the CLI wrapper + # is thin and should be compatible with future Python versions. + CIBW_BUILD: cp${{ env.STABLE_PYTHON_VERSION }}-* + + - name: Upload wheels + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-${{ matrix.os }} + path: wheelhouse/*.whl + if-no-files-found: error + compression-level: 0 + + publish: + name: Publish CLI wheels and sdist + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/pact-python-cli/') + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pact-python-cli + needs: + - build-sdist + - build-wheels + permissions: + contents: write + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Extract release changelog + run: | + version="${{ github.ref_name }}" + version="${version#*/}" + out="${{ runner.temp }}/release-changelog.md" + awk -v ver="$version" ' + /^## / { if (found) exit; if (index($0, ver)) found=1; next } + found { print } + ' pact-python-cli/CHANGELOG.md > "$out" + if [ ! -s "$out" ]; then + printf '> [!WARNING]\n>\n> No changelog entry found for %s.\n' "$version" > "$out" + fi + + - name: Find previous release tag + id: previous-tag + run: | + git fetch --tags --quiet + previous=$(git tag --list 'pact-python-cli/*' --sort=-version:refname \ + | grep -v "^${{ github.ref_name }}$" | head -1) + echo "tag=${previous}" >> "$GITHUB_OUTPUT" + + - name: Download wheels and sdist + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: wheelhouse + merge-multiple: true + + - name: Create GitHub release + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + files: wheelhouse/* + body_path: ${{ runner.temp }}/release-changelog.md + previous_tag: ${{ steps.previous-tag.outputs.tag }} + draft: false + prerelease: false + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + skip-existing: true + packages-dir: wheelhouse diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml new file mode 100644 index 000000000..a12059bce --- /dev/null +++ b/.github/workflows/release-core.yml @@ -0,0 +1,238 @@ +--- +# Release workflow for pact-python (core package). +# +# This workflow handles the full release lifecycle in three stages: +# +# Stage 1: Prepare (trigger: push to main) +# The `prepare` job runs `scripts/release.py prepare core`, which uses +# git-cliff to compute the next semver from conventional commits, updates +# pyproject.toml and CHANGELOG.md, and force-pushes those changes to the +# fixed branch `release/pact-python`. It then creates (or updates the title +# and body of) the release PR targeting main. +# +# Stage 2: Tag (trigger: release PR merged → `closed` event, merged == true) +# When the release PR on `release/pact-python` is merged, the `tag` job runs +# `scripts/release.py tag core`, which reads the version from pyproject.toml +# and pushes a git tag of the form `pact-python/X.Y.Z`. +# +# Stage 3: Publish (trigger: tag push matching `pact-python/*`) +# The `build` and `publish` jobs build the sdist and wheel, create a GitHub +# release with the changelog, and publish the artifacts to PyPI. +# +# Additional: the `build` job also runs on open/updated PRs (not closed) so +# that release PRs can be verified to build correctly before merging. +# +# The `closed` PR type is listed in the trigger so that Stage 2 fires on merge. +# All jobs except `tag` guard against closed-but-not-merged events with +# explicit `if` conditions. +name: release + +on: + push: + branches: + - main + tags: + - pact-python/* + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - closed + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }} + +env: + # Bump to 3.11 to access tomllib, otherwise, we would use the oldest supported Python version + STABLE_PYTHON_VERSION: '311' + HATCH_VERBOSE: '1' + FORCE_COLOR: '1' + +jobs: + complete: + name: Release completion check + if: always() + runs-on: ubuntu-latest + needs: + - prepare + - tag + - build + - publish + steps: + - name: Failed + run: exit 1 + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + + prepare: + name: Update release PR + if: >- + github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: + name: release-pr + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Need full history for git-cliff to determine the next version and + # generate the changelog + fetch-depth: 0 + token: ${{ secrets.GH_TOKEN }} + + - name: Install git-cliff and typos + uses: taiki-e/install-action@7be9fd86bd1707236395105d6e9329dd1511a7e1 # v2.79.0 + with: + tool: git-cliff,typos + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Install Python + run: uv python install ${{ env.STABLE_PYTHON_VERSION }} + + - name: Update release PR + run: uv run python scripts/release.py prepare core + env: + # Require GH_TOKEN (PAT) instead of GITHUB_TOKEN so that the PR can + # itself trigger workflows + GH_TOKEN: ${{ secrets.GH_TOKEN }} + + tag: + name: Create release tag + if: >- + github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref + == 'release/pact-python' + runs-on: ubuntu-latest + environment: + name: release-pr + steps: + - name: Checkout main + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + token: ${{ secrets.GH_TOKEN }} + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Install Python + run: uv python install ${{ env.STABLE_PYTHON_VERSION }} + + - name: Create and push release tag + run: uv run python scripts/release.py tag core + env: + # Require GH_TOKEN (PAT) instead of GITHUB_TOKEN so that the PR can + # itself trigger workflows + GH_TOKEN: ${{ secrets.GH_TOKEN }} + + build: + name: Build source distribution + # Prepare the wheels for upload, and also verify that the build works on + # release PRs before merging. + if: >- + ( + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/pact-python/') + ) || ( + github.event_name == 'pull_request' && + github.event.action != 'closed' && + github.event.pull_request.head.ref == 'release/pact-python' + ) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Install Python + run: uv python install ${{ env.STABLE_PYTHON_VERSION }} + + - name: Install hatch + run: uv tool install hatch + + - name: Build source distribution and wheel + run: hatch build + + - name: Upload sdist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-sdist + path: ./dist/*.tar* + if-no-files-found: error + compression-level: 0 + + - name: Upload wheel + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-whl + path: ./dist/*.whl + if-no-files-found: error + compression-level: 0 + + publish: + name: Publish wheel and sdist + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/pact-python/') + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pact-python + needs: + - build + permissions: + contents: write + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Extract release changelog + run: | + version="${{ github.ref_name }}" + version="${version#*/}" + out="${{ runner.temp }}/release-changelog.md" + awk -v ver="$version" ' + /^## / { if (found) exit; if (index($0, ver)) found=1; next } + found { print } + ' CHANGELOG.md > "$out" + if [ ! -s "$out" ]; then + printf '> [!WARNING]\n>\n> No changelog entry found for %s.\n' "$version" > "$out" + fi + + - name: Find previous release tag + id: previous-tag + run: | + git fetch --tags --quiet + previous=$(git tag --list 'pact-python/*' --sort=-version:refname \ + | grep -v "^${{ github.ref_name }}$" | head -1) + echo "tag=${previous}" >> "$GITHUB_OUTPUT" + + - name: Download wheels and sdist + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: wheelhouse + merge-multiple: true + + - name: Create GitHub release + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + files: wheelhouse/* + body_path: ${{ runner.temp }}/release-changelog.md + previous_tag: ${{ steps.previous-tag.outputs.tag }} + draft: false + prerelease: false + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + skip-existing: true + packages-dir: wheelhouse diff --git a/.github/workflows/release-ffi.yml b/.github/workflows/release-ffi.yml new file mode 100644 index 000000000..fba4dedfc --- /dev/null +++ b/.github/workflows/release-ffi.yml @@ -0,0 +1,282 @@ +--- +# Release workflow for pact-python-ffi (FFI wrapper package). +# +# This package tracks the upstream pact-foundation/pact-reference project and +# versions itself as `{upstream_version}.{N}` (e.g. `0.4.28.0`). The release +# lifecycle runs in three stages: +# +# Stage 1: Prepare (trigger: push to main) +# The `prepare` job runs `scripts/release.py prepare ffi`, which fetches the +# latest `libpact_ffi-v*` release from pact-foundation/pact-reference, +# computes the next wrapper version, updates pyproject.toml and CHANGELOG.md, +# and force-pushes those changes to the fixed branch `release/pact-python-ffi`. +# It then creates (or updates the title and body of) the release PR targeting +# main. +# +# Stage 2: Tag (trigger: release PR merged → `closed` event, merged == true) +# When the release PR on `release/pact-python-ffi` is merged, the `tag` job +# runs `scripts/release.py tag ffi`, which reads the version from +# pyproject.toml and pushes a git tag of the form `pact-python-ffi/X.Y.Z.N`. +# +# Stage 3: Publish (trigger: tag push matching `pact-python-ffi/*`) +# The `build-sdist`, `build-wheels`, and `publish` jobs build the full wheel +# matrix (macOS/Linux/Windows, multiple architectures), create a GitHub +# release with the changelog, and publish all artifacts to PyPI. +# +# Additional: `build-sdist` and `build-wheels` also run on open/updated release +# PRs (not closed) to verify builds before merging. +# +# The `closed` PR type is listed in the trigger so that Stage 2 fires on merge. +# All jobs except `tag` guard against closed-but-not-merged events with +# explicit `if` conditions. +name: release ffi + +on: + push: + branches: + - main + tags: + - pact-python-ffi/* + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - closed + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }} + +env: + STABLE_PYTHON_VERSION: '310' + HATCH_VERBOSE: '1' + FORCE_COLOR: '1' + CIBW_BUILD_FRONTEND: build + +jobs: + complete: + name: Release FFI completion check + if: always() + runs-on: ubuntu-latest + needs: + - prepare + - tag + - build-sdist + - build-wheels + - publish + steps: + - name: Failed + run: exit 1 + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + + prepare: + name: Update FFI release PR + if: >- + github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: + name: release-pr + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Need full history for git-cliff to determine the next version and + # generate the changelog + fetch-depth: 0 + token: ${{ secrets.GH_TOKEN }} + + - name: Install git-cliff and typos + uses: taiki-e/install-action@7be9fd86bd1707236395105d6e9329dd1511a7e1 # v2.79.0 + with: + tool: git-cliff,typos + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Install Python + # Bump to 3.11 to access tomllib + run: uv python install 311 + + - name: Update release PR + run: uv run python scripts/release.py prepare ffi + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + + tag: + name: Create FFI release tag + if: >- + github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref + == 'release/pact-python-ffi' + runs-on: ubuntu-latest + environment: + name: release-pr + steps: + - name: Checkout main + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + token: ${{ secrets.GH_TOKEN }} + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Install Python + # Bump to 3.11 to access tomllib + run: uv python install 311 + + - name: Create and push release tag + run: uv run python scripts/release.py tag ffi + env: + # Require GH_TOKEN (PAT) instead of GITHUB_TOKEN so that the PR can + # itself trigger workflows + GH_TOKEN: ${{ secrets.GH_TOKEN }} + + build-sdist: + name: Build FFI source distribution + # Prepare the wheels for upload, and also verify that the build works on + # release PRs before merging. + if: >- + ( + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/pact-python-ffi/') + ) || ( + github.event_name == 'pull_request' && + github.event.action != 'closed' && + github.event.pull_request.head.ref == 'release/pact-python-ffi' + ) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Install Python + run: uv python install ${{ env.STABLE_PYTHON_VERSION }} + + - name: Install hatch + run: uv tool install hatch + + - name: Build source distribution + working-directory: pact-python-ffi + run: hatch build --target sdist + + - name: Upload sdist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-sdist + path: pact-python-ffi/dist/*.tar* + if-no-files-found: error + compression-level: 0 + + build-wheels: + name: Build FFI wheel on ${{ matrix.os }} + # Prepare the wheels for upload, and also verify that the build works on + # release PRs before merging. + if: >- + ( + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/pact-python-ffi/') + ) || ( + github.event_name == 'pull_request' && + github.event.action != 'closed' && + github.event.pull_request.head.ref == 'release/pact-python-ffi' + ) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - macos-15-intel + - macos-latest + - ubuntu-24.04-arm + - ubuntu-latest + - windows-11-arm + - windows-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build wheels + uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1 + with: + package-dir: pact-python-ffi + env: + # Only target the oldest supported Python version, as we build against + # the stable ABI + CIBW_BUILD: cp${{ env.STABLE_PYTHON_VERSION }}-* + + - name: Upload wheels + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-${{ matrix.os }} + path: wheelhouse/*.whl + if-no-files-found: error + compression-level: 0 + + publish: + name: Publish FFI wheels and sdist + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/pact-python-ffi/') + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pact-python-ffi + needs: + - build-sdist + - build-wheels + permissions: + contents: write + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Extract release changelog + run: | + version="${{ github.ref_name }}" + version="${version#*/}" + out="${{ runner.temp }}/release-changelog.md" + awk -v ver="$version" ' + /^## / { if (found) exit; if (index($0, ver)) found=1; next } + found { print } + ' pact-python-ffi/CHANGELOG.md > "$out" + if [ ! -s "$out" ]; then + printf '> [!WARNING]\n>\n> No changelog entry found for %s.\n' "$version" > "$out" + fi + + - name: Find previous release tag + id: previous-tag + run: | + git fetch --tags --quiet + previous=$(git tag --list 'pact-python-ffi/*' --sort=-version:refname \ + | grep -v "^${{ github.ref_name }}$" | head -1) + echo "tag=${previous}" >> "$GITHUB_OUTPUT" + + - name: Download wheels and sdist + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: wheelhouse + merge-multiple: true + + - name: Create GitHub release + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + files: wheelhouse/* + body_path: ${{ runner.temp }}/release-changelog.md + previous_tag: ${{ steps.previous-tag.outputs.tag }} + draft: false + prerelease: false + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + skip-existing: true + packages-dir: wheelhouse diff --git a/.github/workflows/smartbear-issue-label-added.yml b/.github/workflows/smartbear-issue-label-added.yml deleted file mode 100644 index 8b68fed74..000000000 --- a/.github/workflows/smartbear-issue-label-added.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: SmartBear Supported Issue Label Added - -on: - issues: - types: - - labeled - -jobs: - call-workflow: - uses: pact-foundation/.github/.github/workflows/smartbear-issue-label-added.yml@master - secrets: inherit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..523c1aa86 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,295 @@ +--- +name: test + +on: + push: + branches: + - main + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - ready_for_review + +concurrency: + group: ${{ github.workflow }}-${{ github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + STABLE_PYTHON_VERSION: '3.10' + PYTEST_ADDOPTS: --color=yes + HATCH_VERBOSE: '1' + FORCE_COLOR: '1' + +jobs: + complete: + name: Test completion check + if: always() + + permissions: + contents: none + + runs-on: ubuntu-latest + needs: + - test + - example + - format + - lint + - typecheck + - prek + + steps: + - name: Failed + run: exit 1 + if: | + contains(needs.*.result, 'failure') + || contains(needs.*.result, 'cancelled') + + test: + name: >- + Test Python ${{ matrix.python-version }} + on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }} + + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + python-version: + - '3.10' + - '3.14' + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + submodules: true + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + + - name: Install Python + run: uv python install ${{ matrix.python-version }} + + - name: Set PATH on Windows + if: startsWith(matrix.os, 'windows-') + shell: pwsh + run: echo "$pwd/pact-python-ffi/src/pact_ffi" >> $env:GITHUB_PATH + + - name: Set DYLD_LIBRARY_PATH on macOS + if: startsWith(matrix.os, 'macos-') + run: echo "DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH:$PWD/pact-python-ffi/src/pact_ffi" >> $GITHUB_ENV + + - name: Install Hatch + run: uv tool install hatch + + - name: Run tests + run: hatch run test.py${{ matrix.python-version }}:test --junit-xml=junit.xml --cov-report=xml:coverage-tests.xml + + - name: Run tests (v2) + run: hatch run v2-test.py${{ matrix.python-version }}:test --junit-xml=v2-junit.xml --cov-report=xml:coverage-v2.xml + + - name: Run tests (CLI) + working-directory: pact-python-cli + run: hatch run test.py${{ matrix.python-version }}:test --junit-xml=junit.xml --cov-report=xml:coverage-cli.xml + + - name: Run tests (FFI) + working-directory: pact-python-ffi + run: hatch run test.py${{ matrix.python-version }}:test --junit-xml=junit.xml --cov-report=xml:coverage-ffi.xml + + - name: Upload coverage + if: matrix.python-version == env.STABLE_PYTHON_VERSION && matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: tests + files: coverage-tests.xml,coverage-v2.xml,pact-python-cli/coverage-cli.xml,pact-python-ffi/coverage-ffi.xml + + - name: Upload test results + if: ${{ !cancelled() }} + uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + example: + name: >- + Test Python Example ${{ matrix.python-version }} + on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }} + + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + python-version: + - '3.10' + - '3.14' + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + + - name: Install Python + run: uv python install ${{ matrix.python-version }} + + - name: Run examples + shell: bash + run: | + set -o errexit + set -o pipefail + + while IFS= read -r -d $'\0' file <&3; do + cd "$(dirname "$file")" + echo "Running example in $(pwd)" + uv run --python ${{ matrix.python-version }} --group test --with pytest-cov pytest --junit-xml=junit.xml --cov=pact --cov-report=xml:coverage-example.xml + done 3< <(find "$(pwd)/examples" -name pyproject.toml -print0) + + - name: Upload coverage + if: matrix.python-version == env.STABLE_PYTHON_VERSION && matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: examples + files: examples/**/coverage-example.xml + + - name: Upload test results + if: ${{ !cancelled() }} + uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + format: + name: Format + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + + - name: Install Python + run: uv python install ${{ env.STABLE_PYTHON_VERSION }} + + - name: Install Hatch + run: uv tool install hatch + + - name: Format + run: hatch run format --check --output-format github + + - name: Format (CLI) + working-directory: pact-python-cli + run: hatch run format --check --output-format github + + - name: Format (FFI) + working-directory: pact-python-ffi + run: hatch run format --check --output-format github + lint: + name: Lint + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + + - name: Install Python + run: uv python install ${{ env.STABLE_PYTHON_VERSION }} + + - name: Install Hatch + run: uv tool install hatch + + - name: Lint + run: hatch run lint + + - name: Lint (CLI) + working-directory: pact-python-cli + run: hatch run lint + + - name: Lint (FFI) + working-directory: pact-python-ffi + run: hatch run lint + + typecheck: + name: Typecheck + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + + - name: Install Python + run: uv python install ${{ env.STABLE_PYTHON_VERSION }} + + - name: Install Hatch + run: uv tool install hatch + + - name: Typecheck + run: hatch run typecheck + + - name: Typecheck (CLI) + working-directory: pact-python-cli + run: hatch run typecheck + + prek: + name: Prek + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Install hatch + run: uv tool install hatch + + - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 diff --git a/.github/workflows/trigger_pact_docs_update.yml b/.github/workflows/trigger_pact_docs_update.yml deleted file mode 100644 index 109521d95..000000000 --- a/.github/workflows/trigger_pact_docs_update.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Trigger update to docs.pact.io - -on: - push: - branches: - - master - paths: - - '**.md' - -jobs: - run: - runs-on: ubuntu-latest - steps: - - name: Trigger docs.pact.io update workflow - run: | - curl -X POST https://api.github.com/repos/pact-foundation/docs.pact.io/dispatches \ - -H 'Accept: application/vnd.github.everest-preview+json' \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -d '{"event_type": "pact-python-docs-updated"}' - env: - GITHUB_TOKEN: ${{ secrets.GHTOKENFORTRIGGERINGPACTDOCSUPDATE }} diff --git a/.gitignore b/.gitignore index ba0d76014..761f69890 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,35 @@ -# pact-python specific ignores -e2e/pacts -userserviceclient-userservice.json -detectcontentlambda-contentprovider.json -pact/bin +################################################################################ +## Project Specific +################################################################################ +# Test outputs +examples/tests/pacts + +# Wheels from CIBuildWheel +wheelhouse/ + +# Git worktrees +.worktrees/ + +################################################################################ +## Standard Templates +################################################################################ +## The below sections are sourced from github/gitignore +## +## The following files are included: +## +## - Python.gitignore +## - Global/Backup.gitignore +## - Global/Linux.gitignore +## - Global/Windows.gitignore +## - Global/macOS.gitignore +## - Global/VisualStudioCode.gitignore +## - Global/Emacs.gitignore +## - Global/JetBrains.gitignore + +######################################## +## Python.gitignore +######################################## # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -14,7 +40,6 @@ __pycache__/ # Distribution / packaging .Python -env/ build/ develop-eggs/ dist/ @@ -26,9 +51,12 @@ lib64/ parts/ sdist/ var/ +wheels/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller # Usually these files are written by a python script from a template @@ -43,13 +71,17 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml -*,cover +*.cover +*.py,cover .hypothesis/ +.pytest_cache/ +cover/ # Translations *.mo @@ -58,14 +90,13 @@ coverage.xml # Django stuff: *.log local_settings.py +db.sqlite3 +db.sqlite3-journal # Flask stuff: instance/ .webassets-cache -# Intellij stuff -.idea/ - # Scrapy stuff: .scrapy @@ -73,35 +104,324 @@ instance/ docs/_build/ # PyBuilder +.pybuilder/ target/ -# IPython Notebook +# Jupyter Notebook .ipynb_checkpoints +# IPython +profile_default/ +ipython_config.py + # pyenv -.python-version +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock -# celery beat schedule file +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff celerybeat-schedule +celerybeat.pid -# dotenv -.env +# SageMath parsed files +*.sage.py -# virtualenv +# Environments +.env +.venv +env/ venv/ -.venv/ ENV/ +env.bak/ +venv.bak/ # Spyder project settings .spyderproject +.spyproject # Rope project settings .ropeproject -# VCode stuff +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + + +######################################## +## Global/Backup.gitignore +######################################## +*.bak +*.gho +*.ori +*.orig +*.tmp + + +######################################## +## Global/Linux.gitignore +######################################## +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +######################################## +## Global/Windows.gitignore +######################################### Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +######################################## +## Global/macOS.gitignore +######################################## +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +######################################## +## Global/VisualStudioCode.gitignore +######################################## .vscode/* -*.code-workspace +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + + +######################################## +## Global/Emacs.gitignore +######################################## +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile + +# directory configuration +.dir-locals.el + +# network security +/network-security.data + +######################################## +## Global/JetBrains.gitignore +######################################## +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties -.noseids +# Editor-based Rest Client +.idea/httpRequests +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..6a51a5997 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "compatibility-suite"] + path = tests/compatibility_suite/definition + url = ../pact-compatibility-suite.git diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 000000000..cad769c4a --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,33 @@ +--- +ignores: + - .github/PULL_REQUEST_TEMPLATE.md + +config: + default: true + + # Do not enforce line length + line-length: false + + # Adjust list indentation for 4 spaces + list-marker-space: + ul_single: 3 + ul_multi: 3 + ol_single: 2 + ol_multi: 2 + + ul-indent: + indent: 4 + + # Require fenced code blocks + code-block-style: + style: fenced + + # Disable checking for reference links, as MkDocs generates additional ones that + # are not visible to MarkdownLint. + reference-links-images: false + + strong-style: + style: asterisk + + emphasis-style: + style: underscore diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 3ff4f8937..000000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,6 +0,0 @@ -repos: - - repo: https://github.com/commitizen-tools/commitizen - rev: master - hooks: - - id: commitizen - stages: [commit-msg] diff --git a/.tombi.toml b/.tombi.toml new file mode 100644 index 000000000..622f59bd6 --- /dev/null +++ b/.tombi.toml @@ -0,0 +1,5 @@ +toml-version = "v1.1.0" + +[format] + [format.rules] + indent-sub-tables = true diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..360cbadb8 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,17 @@ +{ + "recommendations": [ + "bierner.github-markdown-preview", + "biomejs.biome", + "charliermarsh.ruff", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "github.vscode-pull-request-github", + "ms-python.debugpy", + "ms-python.mypy-type-checker", + "ms-python.python", + "redhat.vscode-yaml", + "yzhang.markdown-all-in-one", + "tombi-toml.tombi" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..1688b7440 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "python.testing.pytestArgs": [ + "--reruns=0", + "--no-cov", + "--ignore=examples/v2", + "--ignore=tests/v2", + "examples/", + "pact-python-cli/tests/", + "pact-python-ffi/tests/", + "tests/" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..02a996e16 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Pact Python - Quick Reference for AI Agents + +## Commands + +- **Test**: `hatch run test` (all tests) or call `pytest` directly. +- **Lint**: `hatch run lint --fix` (check+fix) or `hatch run format` (auto-fix) +- **Typecheck**: `hatch run typecheck` (all) +- **Examples**: `hatch run example` (run example tests) +- **All checks**: `hatch run all` (format, lint, test, typecheck) +- **V2 tests**: `hatch run v2-test:test` (legacy v2 compatibility tests) + +## Code Style + +- **Imports**: Use generic types (`Iterable`, `Sequence`, `Mapping`) over concrete (`list`, `dict`). Absolute imports only (no relative). +- **Types**: All functions require type annotations. Use `typing` module for generics. +- **Docstrings**: Google-style with Markdown formatting. Link references: `[ClassName.method][pact.module.ClassName.method]` +- **Formatting**: Use `ruff` for linting/formatting. +- **Naming**: Follow PEP 8. Use descriptive names that indicate purpose. +- **Error handling**: Use built-in exceptions (`ValueError`, `TypeError`) for standard errors. Custom exceptions inherit from `PactError`. +- **Validation**: Use early returns to reduce nesting. +- **Comments**: Explain design decisions, not what code does. No comments unless necessary. + +## Testing + +- **Framework**: `pytest` with files prefixed `test_`. Use `@pytest.mark.parametrize` for multiple scenarios. +- **Coverage**: Focus on critical paths, edge cases, error conditions. +- **Fixtures**: Use pytest fixtures for shared setup. Minimize mocking. + +## Project Structure + +- `src/pact/`: Main V3+ codebase (active development) +- `src/pact/v2/`: Legacy V2 code (maintenance only - no new features) +- `pact-python-ffi/`: Low-level FFI bindings (memory management, type conversion only) +- `pact-python-cli/`: CLI wrapper (bundled binaries with system fallback) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf8b8e0a..ecf5b0046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,582 +1,1783 @@ -### 1.7.0 - * 9c1132e - Merge pull request #325 from pact-foundation/pactflow_camelcase (Yousaf Nabi, Fri Jan 27 12:29:59 2023 +0000) - * 44cda33 - chore: /s/Pactflow/PactFlow (Yousaf Nabi, Thu Jan 26 16:11:54 2023 +0000) - * 1bbdd37 - feat: Enhance provider states for pact-message (#322) (nsfrias, Tue Jan 24 17:04:29 2023 +0000) - * 53ca129 - chore: add workflow to create a jira issue for pactflow team when smartbear-supported label added to github issue (Beth Skurrie, Wed Jan 18 10:51:05 2023 +1100) - * d87d54b - fix: setup security issue (#318) (Elliott Murray, Mon Nov 21 09:39:41 2022 +0000) - * 55f2a64 - fix: requirements_dev.txt to reduce vulnerabilities (#317) (Matt Fellows, Sun Nov 6 02:12:30 2022 +1100) -### 1.6.0 - * ceff89b - Publish verify branches (#306) (Yousaf Nabi, Sun Sep 11 11:33:44 2022 +0100) - * 89733d6 - feat: Support verify with branch (#302) (B3nnyL, Sun Sep 11 20:14:13 2022 +1000) - * 42e0db8 - feat: Support publish pact with branch (#300) (B3nnyL, Sun Sep 11 20:06:27 2022 +1000) - * 80d7b13 - chore(test): fix consumer message test (#301) (B3nnyL, Tue Aug 23 23:50:27 2022 +1000) - * 2015f72 - build: Correct download logic when installing. Add a helper target to setup a pyenv via make (#297) (mikegeeves, Sun Jun 19 09:27:07 2022 +0100) - * c17ac70 - docs: Update docs to reflect usage for native Python (#227) (Jiayun Fang, Wed Apr 27 10:00:50 2022 -0700) -### 1.5.2 - * 25823ae - chore: update PACT_STANDALONE_VERSION to 1.88.83 (#292) (Yousaf Nabi, Mon Mar 21 22:14:40 2022 +0000) -### 1.5.1 - * e645b24 - feat: message_pact -> with_metadata() updated to accept term (#289) (sunsathish88, Tue Mar 8 12:08:34 2022 -0500) - * b981865 - docs(examples-consumer): add pip install requirements to the consumer… (#291) (mikegeeves, Sun Mar 6 10:12:32 2022 +0000) - * 4c76ae8 - test(examples): move shared fixtures to a common folder so they can b… (#280) (mikegeeves, Sun Mar 6 10:10:11 2022 +0000) -### 1.5.0 - * 8085be0 - feat: No include pending (#284) (Abraham Gonzalez, Wed Feb 2 13:20:39 2022 +0100) - * f169f3b - ci: python36-support-removed (#283) (mikegeeves, Sat Jan 22 10:26:44 2022 +0000) -### 1.4.6 - * 6c25844 - chore: flake8 config to ignore direnv (Elliott Murray, Mon Jan 3 18:33:47 2022 +0000) - * 891134a - feat(matcher): Allow bytes type in from_term function (#281) (joshua-badger, Mon Jan 3 11:23:40 2022 -0700) - * 588b55d - fix(consumer): ensure a description is provided for all interactions (#278) (mikegeeves, Thu Dec 30 16:57:03 2021 +0000) - * 02643d4 - test(examples-fastapi): tidy FastAPI example, making consistent with Flask (#274) (mikegeeves, Sun Oct 31 21:52:54 2021 +0000) - * bf110e2 - docs: Docs/examples (#273) (Elliott Murray, Tue Oct 26 21:54:00 2021 +0100) -### 1.4.5 - * 695d51f - fix: update standalone to 1.88.77 to fix Let's Encrypt CA issue (Matt Fellows, Mon Oct 11 13:29:34 2021 +1100) -### 1.4.4 - * b90cf3d - fix(ruby): update ruby standalone to support disabling SSL verification via an environment variable (m-aciek, Sat Oct 2 03:04:14 2021 +0200) -### 1.4.3 - * 08f0dc0 - feat: added support for message provider using pact broker (#257) (Fabio Pulvirenti, Sun Sep 5 22:49:51 2021 +0200) -### 1.4.2 - * f2230b6 - chore: Bundle Ruby standalones into dist artifact. (#256) (Taj Pereira, Sun Aug 22 19:53:53 2021 +0930) - * e370786 - chore: Releasing version 1.4.1 (Elliott Murray, Tue Aug 17 18:55:53 2021 +0100) - * 7dc8864 - fix: make uvicorn versions over 0.14 (#255) (Elliott Murray, Tue Aug 17 18:51:52 2021 +0100) - * da49cd7 - chore: Releasing version 1.4.0 (Elliott Murray, Sat Aug 7 10:17:26 2021 +0100) - * 0089937 - fix: issue originating from snyk with requests and urllib (#252) (Elliott Murray, Sat Jul 31 12:46:15 2021 +0100) - * 903371b - feat: added support for message provider (#251) (Fabio Pulvirenti, Sat Jul 31 13:24:19 2021 +0200) - * 2c81029 - chore(snyk): update fastapi (#239) (Elliott Murray, Fri Jun 11 09:12:38 2021 +0100) -### 1.4.1 - * 7dc8864 - fix: make uvicorn versions over 0.14 (#255) (Elliott Murray, Tue Aug 17 18:51:52 2021 +0100) -### 1.4.0 - * 0089937 - fix: issue originating from snyk with requests and urllib (#252) (Elliott Murray, Sat Jul 31 12:46:15 2021 +0100) - * 903371b - feat: added support for message provider (#251) (Fabio Pulvirenti, Sat Jul 31 13:24:19 2021 +0200) - * 2c81029 - chore(snyk): update fastapi (#239) (Elliott Murray, Fri Jun 11 09:12:38 2021 +0100) -### 1.3.9 - * 98d9a4b - chore(ruby): update ruby standalen (#233) (Elliott Murray, Thu May 13 20:21:10 2021 +0100) - * 657e770 - fix: change default from empty string to empty list (#235) (Vasile Tofan, Thu May 13 22:20:47 2021 +0300) - * 99fd965 - chore: Releasing version 1.3.8 (Elliott Murray, Sat May 1 12:26:47 2021 +0100) - * 3c909f1 - docs: example uses date matcher (#231) (Elliott Murray, Sat May 1 11:51:28 2021 +0100) - * 6390144 - fix: fix datetime serialization issues in Format (#230) (Syed Muhammad Dawoud Sheraz Ali, Thu Apr 29 01:49:53 2021 +0500) -### 1.3.8 - * 3c909f1 - docs: example uses date matcher (#231) (Elliott Murray, Sat May 1 11:51:28 2021 +0100) - * 6390144 - fix: fix datetime serialization issues in Format (#230) (Syed Muhammad Dawoud Sheraz Ali, Thu Apr 29 01:49:53 2021 +0500) -### 1.3.7 - * 20f828f - fix(broker): token added to verify steps (#226) (Elliott Murray, Sat Apr 24 13:47:22 2021 +0100) - * c4fe422 - chore: Releasing version 1.3.6 (Elliott Murray, Tue Apr 20 20:58:50 2021 +0100) - * 34160a8 - fix: publish verification results was wrong (#222) (Elliott Murray, Tue Apr 20 20:58:20 2021 +0100) - * 2c0252c - Merge pull request #219 from pact-foundation/ci/revert_snyk_36 (Elliott Murray, Sat Apr 3 11:13:49 2021 +0100) - * 1a162cf - ci: revert docker36 back (Elliott Murray, Sat Apr 3 11:00:37 2021 +0100) - * 4282de4 - Merge pull request #217 from pact-foundation/snyk-fix-8f994ea63cfa41070b04b182dbd11c74 (Elliott Murray, Sat Apr 3 10:54:58 2021 +0100) - * 4eb3fbb - Merge pull request #216 from pact-foundation/snyk-fix-fc54d9c7fe536fffe78fbd34fc5fd7ea (Elliott Murray, Sat Apr 3 10:54:10 2021 +0100) - * 47373ff - fix: docker/py37.Dockerfile to reduce vulnerabilities (snyk-bot, Fri Apr 2 03:13:49 2021 +0000) - * e572221 - fix: docker/py38.Dockerfile to reduce vulnerabilities (snyk-bot, Fri Apr 2 01:10:57 2021 +0000) - * f293dbb - Merge pull request #215 from pact-foundation/snyk-fix-ab489d8931bdf95d6ef0d217aa1b2eb6 (Elliott Murray, Wed Mar 31 09:27:02 2021 +0100) - * 5946872 - fix: docker/py36.Dockerfile to reduce vulnerabilities (snyk-bot, Tue Mar 30 21:41:35 2021 +0000) -### 1.3.6 - * 34160a8 - fix: publish verification results was wrong (#222) (Elliott Murray, Tue Apr 20 20:58:20 2021 +0100) - * 2c0252c - Merge pull request #219 from pact-foundation/ci/revert_snyk_36 (Elliott Murray, Sat Apr 3 11:13:49 2021 +0100) - * 1a162cf - ci: revert docker36 back (Elliott Murray, Sat Apr 3 11:00:37 2021 +0100) - * 4282de4 - Merge pull request #217 from pact-foundation/snyk-fix-8f994ea63cfa41070b04b182dbd11c74 (Elliott Murray, Sat Apr 3 10:54:58 2021 +0100) - * 4eb3fbb - Merge pull request #216 from pact-foundation/snyk-fix-fc54d9c7fe536fffe78fbd34fc5fd7ea (Elliott Murray, Sat Apr 3 10:54:10 2021 +0100) - * 47373ff - fix: docker/py37.Dockerfile to reduce vulnerabilities (snyk-bot, Fri Apr 2 03:13:49 2021 +0000) - * e572221 - fix: docker/py38.Dockerfile to reduce vulnerabilities (snyk-bot, Fri Apr 2 01:10:57 2021 +0000) - * f293dbb - Merge pull request #215 from pact-foundation/snyk-fix-ab489d8931bdf95d6ef0d217aa1b2eb6 (Elliott Murray, Wed Mar 31 09:27:02 2021 +0100) - * 5946872 - fix: docker/py36.Dockerfile to reduce vulnerabilities (snyk-bot, Tue Mar 30 21:41:35 2021 +0000) - * d6c5f4a - chore: Releasing version 1.3.5 (Elliott Murray, Sun Mar 28 15:32:45 2021 +0100) - * 5864e47 - Merge pull request #213 from pact-foundation/fix/revert_some_publish (Elliott Murray, Sun Mar 28 15:27:50 2021 +0100) - * 94e597a - fix(publish): fixing the fix. Pact Python api uses only publish_version and ensures it follows that (Elliott Murray, Sun Mar 28 15:20:04 2021 +0100) - * e00f320 - chore: Releasing version 1.3.4 (Elliott Murray, Sat Mar 27 21:26:29 2021 +0000) - * c778c71 - Merge pull request #212 from pact-foundation/fix/verify_in_provider (Elliott Murray, Sat Mar 27 18:59:25 2021 +0000) - * ea0b64a - fix: verifier should now publish (Elliott Murray, Sat Mar 27 16:21:25 2021 +0000) - * 2c8779b - chore: Releasing version 1.3.3 (Elliott Murray, Thu Mar 25 21:23:29 2021 +0000) - * 5e282ff - Merge pull request #211 from anneschuth/fix/pass-pact-dir (Elliott Murray, Thu Mar 25 21:21:56 2021 +0000) - * 987c4fc - fix: pass pact_dir to publish() (Anne Schuth, Thu Mar 25 10:06:54 2021 +0100) - * 23a5129 - chore: Releasing version 1.3.2 (Elliott Murray, Sun Mar 21 14:32:50 2021 +0000) - * 57c8ae8 - Merge pull request #209 from pact-foundation/bug/fix_test_dir (Elliott Murray, Sun Mar 21 14:29:40 2021 +0000) - * af3dadf - fix: remove pacts from examples (Elliott Murray, Sun Mar 21 14:21:10 2021 +0000) - * 579f3f8 - fix: ensure path is passed to broker and allow running from root rather than test file (Elliott Murray, Sun Mar 21 13:18:01 2021 +0000) - * 7e0feab - Merge pull request #208 from pact-foundation/dependabot/pip/examples/e2e/jinja2-2.11.3 (Elliott Murray, Sat Mar 20 12:46:32 2021 +0000) - * f82c008 - chore(deps): bump jinja2 from 2.11.2 to 2.11.3 in /examples/e2e (dependabot[bot], Sat Mar 20 04:53:58 2021 +0000) - * ea6f635 - Merge pull request #206 from pact-foundation/chore/use-testcontainers (Elliott Murray, Sun Mar 14 11:05:01 2021 +0000) - * 565bc80 - chore: added some docs about how to use the e2e example (Elliott Murray, Sun Mar 14 11:02:35 2021 +0000) - * 1b4be80 - chore: spiking testcontainers (Elliott Murray, Sat Mar 13 11:15:52 2021 +0000) - * 01e4ec4 - chore: wip on using test containers on examples (Elliott Murray, Tue Mar 2 21:30:48 2021 +0000) - * 882f4a2 - Merge pull request #204 from pact-foundation/chore/use_pytest (Elliott Murray, Sat Feb 27 10:58:19 2021 +0000) - * 261f24b - chore: more clean up (Elliott Murray, Sat Feb 27 10:10:23 2021 +0000) - * 5a0934c - chore: update ci stuff (Elliott Murray, Sat Feb 27 09:57:31 2021 +0000) - * 66e79e2 - chore: move from nose to pytests as we are now 3.6+ (Elliott Murray, Sat Feb 27 09:34:37 2021 +0000) - * 6aee3e2 - chore: Releasing version 1.3.1 (Elliott Murray, Sat Feb 27 09:16:35 2021 +0000) - * 4440022 - Merge pull request #203 from pact-foundation/fix/version_confusion (Elliott Murray, Sat Feb 27 09:15:08 2021 +0000) - * 9cac2d7 - fix: introduced and renamed specification version (Elliott Murray, Tue Feb 23 21:22:36 2021 +0000) - * 64d7bdc - chore: Releasing version 1.3.0 (Elliott Murray, Tue Jan 26 18:45:58 2021 +0000) - * eaa90e1 - Merge pull request #194 from williaminfante/feat/pact-message-2 (Elliott Murray, Mon Jan 25 08:48:00 2021 +0000) - * 5ed73db - test: consider publish to broker with no pact_dir argument (William Infante, Mon Jan 25 17:19:08 2021 +1100) - * e097153 - docs: update readme (William Infante, Mon Jan 25 17:18:35 2021 +1100) - * fc0d91c - feat: address PR comments (William Infante, Mon Jan 25 10:30:33 2021 +1100) - * 5448d8c - test: remove mock and check generated json file (William Infante, Wed Jan 20 21:07:39 2021 +1100) - * abd3574 - fix: few more tests to improve coverage (Tuan Pham, Wed Jan 20 09:23:13 2021 +1100) - * 0ef971f - fix: improve test coverage (Tuan Pham, Tue Jan 19 15:11:11 2021 +1100) - * e543f04 - chore: add missing import (William Infante, Tue Jan 19 13:58:17 2021 +1100) - * bc7ff78 - chore: pydocstyle (Tuan Pham, Tue Jan 19 10:40:12 2021 +1100) - * d4235f9 - chore: flake8, clean up deadcode (Tuan Pham, Tue Jan 19 00:53:03 2021 +1100) - * 19827d0 - chore: remove test param for provider (Tuan Pham, Mon Jan 18 16:06:42 2021 +1100) - * 912b477 - chore: flake8 revert (Tuan Pham, Mon Jan 18 16:04:00 2021 +1100) - * 4a730ae - fix: revert changes to quotes (Tuan Pham, Mon Jan 18 15:57:14 2021 +1100) - * cfe35cc - feat: update message hander to be independent of pact (William Infante, Mon Jan 18 13:12:17 2021 +1100) - * 12b4a50 - fix: flake8 warning (Tuan Pham, Mon Jan 18 12:50:50 2021 +1100) - * 7afe693 - test: update message handler condition based on content (William Infante, Mon Jan 18 12:37:50 2021 +1100) - * 79106bb - feat: move publish function to broker class (Tuan Pham, Mon Jan 18 11:30:35 2021 +1100) - * a04b954 - docs: add readme for message consumer (William Infante, Mon Jan 18 09:48:01 2021 +1100) - * 8672e2f - feat: update handler to handle error exceptions (William Infante, Fri Jan 15 16:24:38 2021 +1100) - * 47b7434 - feat: change dummy handler to a message handler (William Infante, Fri Jan 15 14:06:55 2021 +1100) - * a18ce3e - test: create external dummy handler in test (William Infante, Fri Jan 15 12:31:49 2021 +1100) - * 7358049 - chore: remove log_dir, refactor test (Tuan Pham, Fri Jan 15 09:11:55 2021 +1100) - * 6718b4c - feat: update message pact tests (William Infante, Thu Jan 14 16:12:44 2021 +1100) - * bf9864f - feat: add more test (William Infante, Thu Jan 14 16:09:52 2021 +1100) - * 84681b4 - fix: try different way to mock (Tuan Pham, Thu Jan 14 15:10:36 2021 +1100) - * 86cfe8e - chore: add generate_pact_test (Tuan Pham, Thu Jan 14 14:26:42 2021 +1100) - * a1c19b6 - fix: add missing conftest (Tuan Pham, Thu Jan 14 11:02:08 2021 +1100) - * a98850a - chore: add missing files in src (Tuan Pham, Thu Jan 14 10:53:35 2021 +1100) - * 63452aa - chore: fix bad merge (Tuan Pham, Thu Jan 14 10:47:43 2021 +1100) - * 85fc77f - feat: add pact-message integration test (Tuan Pham, Thu Jan 14 10:32:02 2021 +1100) - * 11793f5 - feat: add pact-message integration (Tuan Pham, Thu Jan 14 10:30:35 2021 +1100) - * 8546a26 - fix: linting (Tuan Pham, Thu Jan 14 10:38:52 2021 +1100) - * 65b69d7 - fix: remove publish fn for now (Tuan Pham, Thu Jan 14 10:38:10 2021 +1100) - * e31dd45 - feat: add constants test (William Infante, Wed Jan 13 17:28:45 2021 +1100) - * 955dbe1 - feat: update MessageConsumer and tests (William Infante, Wed Jan 13 16:38:30 2021 +1100) - * af5c9fb - feat: create basic tests for single pact message (William Infante, Wed Jan 13 15:52:07 2021 +1100) - * fea27c8 - feat: single message flow (William Infante, Wed Jan 13 15:03:41 2021 +1100) - * 9047855 - feat: add MessageConsumer (William Infante, Wed Jan 13 12:45:19 2021 +1100) - * 40edd39 - feat: initial interface (William Infante, Tue Jan 12 16:59:46 2021 +1100) - * 2946242 - Merge pull request #198 from pact-foundation/chore/deprecate_python35 (Elliott Murray, Sat Jan 23 15:26:01 2021 +0000) - * 0111698 - fix: add e2e example test into ci back in (Elliott Murray, Sat Jan 23 15:25:07 2021 +0000) - * 0cdc7e9 - chore: remove python35 and 34 and add 39 (Elliott Murray, Sat Jan 23 15:20:47 2021 +0000) - * 3885c60 - Merge pull request #197 from pact-foundation/fix/pull_request_trigger_workflow (Elliott Murray, Sat Jan 23 10:51:06 2021 +0000) - * 925b0ac - ci: pr not triggering workflow (Elliott Murray, Sat Jan 23 10:44:36 2021 +0000) - * 8f9a925 - Merge pull request #195 from cdambo/pass-pact-dir-to-cli (Elliott Murray, Sat Jan 23 10:39:53 2021 +0000) - * 545fc37 - fix: send to cli pact_files with the pact_dir in their path (Chanan Damboritz, Tue Jan 19 18:51:17 2021 +0200) -### 1.3.5 - * 5864e47 - Merge pull request #213 from pact-foundation/fix/revert_some_publish (Elliott Murray, Sun Mar 28 15:27:50 2021 +0100) - * 94e597a - fix(publish): fixing the fix. Pact Python api uses only publish_version and ensures it follows that (Elliott Murray, Sun Mar 28 15:20:04 2021 +0100) -### 1.3.4 - * c778c71 - Merge pull request #212 from pact-foundation/fix/verify_in_provider (Elliott Murray, Sat Mar 27 18:59:25 2021 +0000) - * ea0b64a - fix: verifier should now publish (Elliott Murray, Sat Mar 27 16:21:25 2021 +0000) -### 1.3.3 - * 5e282ff - Merge pull request #211 from anneschuth/fix/pass-pact-dir (Elliott Murray, Thu Mar 25 21:21:56 2021 +0000) - * 987c4fc - fix: pass pact_dir to publish() (Anne Schuth, Thu Mar 25 10:06:54 2021 +0100) -### 1.3.2 - * 57c8ae8 - Merge pull request #209 from pact-foundation/bug/fix_test_dir (Elliott Murray, Sun Mar 21 14:29:40 2021 +0000) - * af3dadf - fix: remove pacts from examples (Elliott Murray, Sun Mar 21 14:21:10 2021 +0000) - * 579f3f8 - fix: ensure path is passed to broker and allow running from root rather than test file (Elliott Murray, Sun Mar 21 13:18:01 2021 +0000) - * 7e0feab - Merge pull request #208 from pact-foundation/dependabot/pip/examples/e2e/jinja2-2.11.3 (Elliott Murray, Sat Mar 20 12:46:32 2021 +0000) - * f82c008 - chore(deps): bump jinja2 from 2.11.2 to 2.11.3 in /examples/e2e (dependabot[bot], Sat Mar 20 04:53:58 2021 +0000) - * ea6f635 - Merge pull request #206 from pact-foundation/chore/use-testcontainers (Elliott Murray, Sun Mar 14 11:05:01 2021 +0000) - * 565bc80 - chore: added some docs about how to use the e2e example (Elliott Murray, Sun Mar 14 11:02:35 2021 +0000) - * 1b4be80 - chore: spiking testcontainers (Elliott Murray, Sat Mar 13 11:15:52 2021 +0000) - * 01e4ec4 - chore: wip on using test containers on examples (Elliott Murray, Tue Mar 2 21:30:48 2021 +0000) - * 882f4a2 - Merge pull request #204 from pact-foundation/chore/use_pytest (Elliott Murray, Sat Feb 27 10:58:19 2021 +0000) - * 261f24b - chore: more clean up (Elliott Murray, Sat Feb 27 10:10:23 2021 +0000) - * 5a0934c - chore: update ci stuff (Elliott Murray, Sat Feb 27 09:57:31 2021 +0000) - * 66e79e2 - chore: move from nose to pytests as we are now 3.6+ (Elliott Murray, Sat Feb 27 09:34:37 2021 +0000) -### 1.3.1 - * 4440022 - Merge pull request #203 from pact-foundation/fix/version_confusion (Elliott Murray, Sat Feb 27 09:15:08 2021 +0000) - * 9cac2d7 - fix: introduced and renamed specification version (Elliott Murray, Tue Feb 23 21:22:36 2021 +0000) -### 1.3.0 - * eaa90e1 - Merge pull request #194 from williaminfante/feat/pact-message-2 (Elliott Murray, Mon Jan 25 08:48:00 2021 +0000) - * 5ed73db - test: consider publish to broker with no pact_dir argument (William Infante, Mon Jan 25 17:19:08 2021 +1100) - * e097153 - docs: update readme (William Infante, Mon Jan 25 17:18:35 2021 +1100) - * fc0d91c - feat: address PR comments (William Infante, Mon Jan 25 10:30:33 2021 +1100) - * 5448d8c - test: remove mock and check generated json file (William Infante, Wed Jan 20 21:07:39 2021 +1100) - * abd3574 - fix: few more tests to improve coverage (Tuan Pham, Wed Jan 20 09:23:13 2021 +1100) - * 0ef971f - fix: improve test coverage (Tuan Pham, Tue Jan 19 15:11:11 2021 +1100) - * e543f04 - chore: add missing import (William Infante, Tue Jan 19 13:58:17 2021 +1100) - * bc7ff78 - chore: pydocstyle (Tuan Pham, Tue Jan 19 10:40:12 2021 +1100) - * d4235f9 - chore: flake8, clean up deadcode (Tuan Pham, Tue Jan 19 00:53:03 2021 +1100) - * 19827d0 - chore: remove test param for provider (Tuan Pham, Mon Jan 18 16:06:42 2021 +1100) - * 912b477 - chore: flake8 revert (Tuan Pham, Mon Jan 18 16:04:00 2021 +1100) - * 4a730ae - fix: revert changes to quotes (Tuan Pham, Mon Jan 18 15:57:14 2021 +1100) - * cfe35cc - feat: update message hander to be independent of pact (William Infante, Mon Jan 18 13:12:17 2021 +1100) - * 12b4a50 - fix: flake8 warning (Tuan Pham, Mon Jan 18 12:50:50 2021 +1100) - * 7afe693 - test: update message handler condition based on content (William Infante, Mon Jan 18 12:37:50 2021 +1100) - * 79106bb - feat: move publish function to broker class (Tuan Pham, Mon Jan 18 11:30:35 2021 +1100) - * a04b954 - docs: add readme for message consumer (William Infante, Mon Jan 18 09:48:01 2021 +1100) - * 8672e2f - feat: update handler to handle error exceptions (William Infante, Fri Jan 15 16:24:38 2021 +1100) - * 47b7434 - feat: change dummy handler to a message handler (William Infante, Fri Jan 15 14:06:55 2021 +1100) - * a18ce3e - test: create external dummy handler in test (William Infante, Fri Jan 15 12:31:49 2021 +1100) - * 7358049 - chore: remove log_dir, refactor test (Tuan Pham, Fri Jan 15 09:11:55 2021 +1100) - * 6718b4c - feat: update message pact tests (William Infante, Thu Jan 14 16:12:44 2021 +1100) - * bf9864f - feat: add more test (William Infante, Thu Jan 14 16:09:52 2021 +1100) - * 84681b4 - fix: try different way to mock (Tuan Pham, Thu Jan 14 15:10:36 2021 +1100) - * 86cfe8e - chore: add generate_pact_test (Tuan Pham, Thu Jan 14 14:26:42 2021 +1100) - * a1c19b6 - fix: add missing conftest (Tuan Pham, Thu Jan 14 11:02:08 2021 +1100) - * a98850a - chore: add missing files in src (Tuan Pham, Thu Jan 14 10:53:35 2021 +1100) - * 63452aa - chore: fix bad merge (Tuan Pham, Thu Jan 14 10:47:43 2021 +1100) - * 85fc77f - feat: add pact-message integration test (Tuan Pham, Thu Jan 14 10:32:02 2021 +1100) - * 11793f5 - feat: add pact-message integration (Tuan Pham, Thu Jan 14 10:30:35 2021 +1100) - * 8546a26 - fix: linting (Tuan Pham, Thu Jan 14 10:38:52 2021 +1100) - * 65b69d7 - fix: remove publish fn for now (Tuan Pham, Thu Jan 14 10:38:10 2021 +1100) - * e31dd45 - feat: add constants test (William Infante, Wed Jan 13 17:28:45 2021 +1100) - * 955dbe1 - feat: update MessageConsumer and tests (William Infante, Wed Jan 13 16:38:30 2021 +1100) - * af5c9fb - feat: create basic tests for single pact message (William Infante, Wed Jan 13 15:52:07 2021 +1100) - * fea27c8 - feat: single message flow (William Infante, Wed Jan 13 15:03:41 2021 +1100) - * 9047855 - feat: add MessageConsumer (William Infante, Wed Jan 13 12:45:19 2021 +1100) - * 40edd39 - feat: initial interface (William Infante, Tue Jan 12 16:59:46 2021 +1100) - * 2946242 - Merge pull request #198 from pact-foundation/chore/deprecate_python35 (Elliott Murray, Sat Jan 23 15:26:01 2021 +0000) - * 0111698 - fix: add e2e example test into ci back in (Elliott Murray, Sat Jan 23 15:25:07 2021 +0000) - * 0cdc7e9 - chore: remove python35 and 34 and add 39 (Elliott Murray, Sat Jan 23 15:20:47 2021 +0000) - * 3885c60 - Merge pull request #197 from pact-foundation/fix/pull_request_trigger_workflow (Elliott Murray, Sat Jan 23 10:51:06 2021 +0000) - * 925b0ac - ci: pr not triggering workflow (Elliott Murray, Sat Jan 23 10:44:36 2021 +0000) - * 8f9a925 - Merge pull request #195 from cdambo/pass-pact-dir-to-cli (Elliott Murray, Sat Jan 23 10:39:53 2021 +0000) - * 545fc37 - fix: send to cli pact_files with the pact_dir in their path (Chanan Damboritz, Tue Jan 19 18:51:17 2021 +0200) -### 1.2.11 - * ba10318 - Merge pull request #192 from pact-foundation/fix/deploy_wheel_fix (Elliott Murray, Tue Dec 29 20:05:52 2020 +0000) - * 289e784 - fix: not creating wheel (Elliott Murray, Tue Dec 29 20:00:19 2020 +0000) - * d217e67 - chore: Releasing version 1.2.10 (Elliott Murray, Sat Dec 19 12:41:02 2020 +0000) - * 9438449 - Merge pull request #191 from pact-foundation/build-and-test-with-github-actions (Elliott Murray, Sat Dec 19 12:38:23 2020 +0000) - * 2796ef5 - docs: Added badge to README (Elliott Murray, Sat Dec 19 09:37:22 2020 +0000) - * f1b6968 - ci: add publishing actions (Matthew Balvanz, Thu Dec 17 09:09:10 2020 -0600) - * 287a32e - ci: removed Travis CI configuration (Matthew Balvanz, Wed Dec 16 21:18:09 2020 -0600) - * 77dd4d5 - ci(github actions): added Github Actions configuration for build and test (Matthew Balvanz, Wed Dec 16 21:07:06 2020 -0600) - * d6f02ca - Merge pull request #189 from noelslice/master (Elliott Murray, Fri Dec 4 08:39:33 2020 +0000) - * c24a73f - docs: typo in pact-verifier help string: PUT -> POST for --provider-states-setup-url (Noel Dawe, Thu Dec 3 22:39:31 2020 -0500) - * 4cdabd1 - Merge pull request #188 from pact-foundation/docs/example/fastapi (Elliott Murray, Sun Nov 29 14:53:24 2020 +0000) - * 6728cb9 - docs(example): created example and have relative imports kinda working. Provider not working as it cant find one of our urls (Elliott Murray, Sun Nov 29 13:34:42 2020 +0000) - * 9358097 - Merge pull request #184 from pact-foundation/chore/update_python_version (Elliott Murray, Sat Nov 28 10:06:53 2020 +0000) - * 48a2a21 - Merge pull request #186 from jstoebel/patch-2 (Elliott Murray, Sat Nov 21 10:39:48 2020 +0000) - * 74f9a4f - docs: fix small typo in `with_request` doc string (Jacob Stoebel, Wed Nov 18 14:51:03 2020 -0500) - * 4e4ed26 - chore: added run test to travis (Elliott Murray, Sun Nov 1 11:49:38 2020 +0000) - * 37e2f3a - chore: wqshell script to run flask in exmaples (Elliott Murray, Sun Nov 1 11:41:59 2020 +0000) - * b5d9d7b - chore(upgrade): upgrade python version to 3.8 (Elliott Murray, Sun Nov 1 11:12:10 2020 +0000) -### 1.2.10 - * 9438449 - Merge pull request #191 from pact-foundation/build-and-test-with-github-actions (Elliott Murray, Sat Dec 19 12:38:23 2020 +0000) - * 2796ef5 - docs: Added badge to README (Elliott Murray, Sat Dec 19 09:37:22 2020 +0000) - * f1b6968 - ci: add publishing actions (Matthew Balvanz, Thu Dec 17 09:09:10 2020 -0600) - * 287a32e - ci: removed Travis CI configuration (Matthew Balvanz, Wed Dec 16 21:18:09 2020 -0600) - * 77dd4d5 - ci(github actions): added Github Actions configuration for build and test (Matthew Balvanz, Wed Dec 16 21:07:06 2020 -0600) - * d6f02ca - Merge pull request #189 from noelslice/master (Elliott Murray, Fri Dec 4 08:39:33 2020 +0000) - * c24a73f - docs: typo in pact-verifier help string: PUT -> POST for --provider-states-setup-url (Noel Dawe, Thu Dec 3 22:39:31 2020 -0500) - * 4cdabd1 - Merge pull request #188 from pact-foundation/docs/example/fastapi (Elliott Murray, Sun Nov 29 14:53:24 2020 +0000) - * 6728cb9 - docs(example): created example and have relative imports kinda working. Provider not working as it cant find one of our urls (Elliott Murray, Sun Nov 29 13:34:42 2020 +0000) - * 9358097 - Merge pull request #184 from pact-foundation/chore/update_python_version (Elliott Murray, Sat Nov 28 10:06:53 2020 +0000) - * 48a2a21 - Merge pull request #186 from jstoebel/patch-2 (Elliott Murray, Sat Nov 21 10:39:48 2020 +0000) - * 74f9a4f - docs: fix small typo in `with_request` doc string (Jacob Stoebel, Wed Nov 18 14:51:03 2020 -0500) - * 4e4ed26 - chore: added run test to travis (Elliott Murray, Sun Nov 1 11:49:38 2020 +0000) - * 37e2f3a - chore: wqshell script to run flask in exmaples (Elliott Murray, Sun Nov 1 11:41:59 2020 +0000) - * b5d9d7b - chore(upgrade): upgrade python version to 3.8 (Elliott Murray, Sun Nov 1 11:12:10 2020 +0000) -### 1.2.9 - * 4430681 - Merge pull request #183 from thatguysimon/feat/verifier-class-consumer-version-selectors (Elliott Murray, Mon Oct 19 15:35:47 2020 +0100) - * 683a931 - fix: Fix flaky tests using OrderedDict (Simon Nizov, Mon Oct 19 17:21:21 2020 +0300) - * 33be267 - style: Fix one more linting issue (Simon Nizov, Mon Oct 19 11:22:05 2020 +0300) - * e7c87ce - style: Fix linting issues (Simon Nizov, Mon Oct 19 11:16:59 2020 +0300) - * ee2eda0 - feat(verifier): Allow setting consumer_version_selectors on Verifier (Simon Nizov, Mon Oct 19 11:01:18 2020 +0300) -### 1.2.8 - * 4c68fd4 - Merge pull request #182 from thatguysimon/feat/enable-wip-pacts (Elliott Murray, Sat Oct 17 16:00:50 2020 +0100) - * 9ea14d3 - refactor: Extract input validation in call_verify out into a dedicated method (Simon Nizov, Sat Oct 17 17:27:49 2020 +0300) - * 5a5969d - fix: Fix command building bug (Simon Nizov, Sat Oct 17 15:40:55 2020 +0300) - * b8c0006 - style: Fix linting (Simon Nizov, Sat Oct 17 15:18:29 2020 +0300) - * fc3d7ae - feat(verifier): Support include-wip-pacts-since in CLI (Simon Nizov, Sat Oct 17 15:03:38 2020 +0300) - * a0eca4c - Merge pull request #180 from elliottmurray/docs/example_flaskr (Elliott Murray, Fri Oct 16 11:13:11 2020 +0100) - * a8a07d4 - docs(examples): changed provider example to use atexit (Elliott Murray, Fri Oct 16 10:54:25 2020 +0100) - * 186f4f4 - Merge pull request #179 from pact-foundation/docs/example_readme (Elliott Murray, Thu Oct 15 10:13:13 2020 +0100) - * 2f66618 - docs(examples): tweak to readme (Elliott Murray, Thu Oct 15 10:08:52 2020 +0100) -### 1.2.7 - * 90b71d2 - Merge pull request #178 from pact-foundation/fix/custom_header_typo (Elliott Murray, Fri Oct 9 12:47:37 2020 +0100) - * b07ef69 - fix(verifier): headers not propogated properly (Elliott Murray, Fri Oct 9 12:24:25 2020 +0100) - * 0e9b71c - Merge pull request #177 from pact-foundation/docs/remove_handcrafted_broker (Elliott Murray, Fri Oct 9 12:01:24 2020 +0100) - * 2db7008 - docs(examples): removed manaul publish to broker (Elliott Murray, Fri Oct 9 11:54:30 2020 +0100) -### 1.2.6 - * 1192bd6 - Merge pull request #173 from copalco/master (Elliott Murray, Thu Sep 10 15:30:07 2020 +0100) - * 5db7100 - feat(verifier): allow to use unauthenticated brokers (Piotr Kopalko, Thu Sep 10 14:12:12 2020 +0200) -### 1.2.5 - * 46372c7 - Merge pull request #171 from m-aciek/enable-pending (Elliott Murray, Wed Sep 9 10:03:02 2020 +0100) - * e840587 - fix(verifier): remove superfluous verbose mentions (Maciej Olko, Sat Sep 5 21:33:52 2020 +0200) - * c64bec1 - refactor(verifier): add enable_pending to signature of verify methods (Maciej Olko, Sat Sep 5 21:32:33 2020 +0200) - * e6c9ed0 - feat(verifier): support --enable-pending flag in CLI (Maciej Olko, Thu Sep 3 15:33:40 2020 +0200) - * 2b57446 - feat(verifier): pass enable_pending flag in Verifier's methods (Maciej Olko, Thu Sep 3 17:03:08 2020 +0200) - * d51c88d - test: bump mock to 3.0.5 (m-aciek, Thu Sep 3 23:42:00 2020 +0200) - * 39de1f3 - feat(verifier): add enable_pending argument handling in verify wrapper (Maciej Olko, Thu Sep 3 15:33:07 2020 +0200) - * fc6c365 - fix(verifier): remove superfluous option from verify CLI command (Maciej Olko, Thu Sep 3 13:30:57 2020 +0200) - * fbbd5fa - ci(pre-commit): add commitizen to pre-commit configuration (Maciej Olko, Thu Sep 3 17:19:45 2020 +0200) -### 1.2.4 - * a594e22 - Merge pull request #170 from alecgerona/feat/consumer-version-selector (Elliott Murray, Thu Aug 27 15:21:45 2020 +0100) - * 05c5e41 - docs(cli): improve cli help grammar (Alexandre Gerona, Thu Aug 27 06:28:56 2020 +0800) - * 49d5f7c - docs: update README.md with relevant option documentation (Alexandre Gerona, Thu Aug 27 06:22:37 2020 +0800) - * 5a99528 - feat(cli): add consumer-version-selector option (Alexandre Gerona, Thu Aug 27 06:22:07 2020 +0800) -### 1.2.3 - * 8188d88 - chore: fix release script (Elliott Murray, Wed Aug 26 12:46:10 2020 +0100) - * e0e5106 - Merge pull request #169 from pact-foundation/chore/update_pr_scripts (Elliott Murray, Wed Aug 26 10:24:47 2020 +0100) - * 81fd653 - chore: release script updates version automaitcally now (Elliott Murray, Wed Aug 26 10:16:14 2020 +0100) - * 773d3f9 - chore: script now uses gh over hub (Elliott Murray, Wed Aug 26 10:03:06 2020 +0100) - * 468e4ad - Merge pull request #168 from pact-foundation/chore/upgrade-to-pact-ruby-standalone-1-88-3 (Elliott Murray, Wed Aug 26 09:49:33 2020 +0100) - * ce944fe - feat: update standalone to 1.88.3 (Elliott Murray, Wed Aug 26 09:08:27 2020 +0100) -### 1.2.2 - * 2c52053 - Merge pull request #167 from pact-foundation/feat/add_env_vars_verify (Elliott Murray, Mon Aug 24 16:08:04 2020 +0100) - * ce62588 - feat: added env vars for broker verify (Elliott Murray, Mon Aug 24 16:03:44 2020 +0100) - * 880fff2 - Merge pull request #165 from pact-foundation/docs/https_fix (Elliott Murray, Thu Aug 20 12:43:12 2020 +0100) - * 1a3605e - docs: https svg (Elliott Murray, Thu Aug 20 12:37:01 2020 +0100) -### 1.2.1 - * 69a4a9a - Merge pull request #163 from elliottmurray/fix/custom_header (Elliott Murray, Sat Aug 8 10:17:20 2020 +0100) - * 88b7d9f - fix: custom headers had a typo (Elliott Murray, Sat Aug 1 11:08:54 2020 +0100) - * f501f19 - Merge pull request #161 from pact-foundation/docs/verifier_docs_examples (Elliott Murray, Fri Jul 24 12:30:35 2020 +0100) - * 9875c71 - docs: merged 2 examples (Elliott Murray, Fri Jul 24 12:00:37 2020 +0100) - * 6f0d3ac - docs: Example code verifier (Elliott Murray, Fri Jul 24 11:31:17 2020 +0100) -### 1.2.0 - * 2b844c5 - Merge pull request #159 from pact-foundation/feat/fix_provider_classs (Elliott Murray, Fri Jul 24 09:47:46 2020 +0100) - * 9c565bb - feat: fixing up tests and examples and code for provider class (Elliott Murray, Mon Jul 20 15:57:49 2020 +0100) - * d4072ed - Merge pull request #156 from pact-foundation/feat/provider_verifier (Elliott Murray, Thu Jul 16 13:31:18 2020 +0100) - * 926a611 - feat: create beta verifier class and api (Elliott Murray, Wed Jun 10 21:31:47 2020 +0100) - * 4635a07 - chore: added semantic yml for git messages (Elliott Murray, Sun Jun 28 12:43:24 2020 +0100) - * ff9894a - Merge pull request #154 from elliottmurray/style/git_message (Elliott Murray, Sat Jun 27 13:31:16 2020 +0100) - * be6697f - fix: change to head from master (Elliott Murray, Sat Jun 27 13:08:08 2020 +0100) -### 1.1.0 - * 1079417 - test (Elliott Murray, Thu Jun 25 10:02:14 2020 +0100) - * 7fe1ef4 - Releasing version 1.1.0 (Elliott Murray, Thu Jun 25 09:41:42 2020 +0100) - * fafc3d5 - Merge pull request #147 from pact-foundation/feat/add_logging_params (Elliott Murray, Thu Jun 25 09:24:34 2020 +0100) - * 8ce7d44 - Added logging params (Elliott Murray, Wed Jun 24 11:58:25 2020 +0100) - * b6450b8 - Merge pull request #146 from pact-foundation/chore/upgrade-to-pact-ruby-standalone-1-86-0 (Elliott Murray, Wed Jun 24 10:59:29 2020 +0100) - * bf43d8a - feat: update standalone to 1.86.0 (Beth Skurrie, Wed Jun 24 09:31:18 2020 +1000) - * 529dfb7 - Merge pull request #145 from jstoebel/patch-1 (Elliott Murray, Thu Jun 11 12:00:51 2020 +0100) - * 9359d34 - Remove typo from examples/e2e requirements.txt (Jacob Stoebel, Thu Jun 11 06:47:02 2020 -0400) - * aee95ed - Merge pull request #144 from pact-foundation/chore_cleanup (Elliott Murray, Wed Jun 10 21:38:12 2020 +0100) - * 9c71ea0 - chore: removed some files and moved a few things around (Elliott Murray, Wed Jun 10 21:33:37 2020 +0100) - - ### v1.0.1 - * 8c78ff7 - Releasing version 1.0.1 (Elliott Murray, Wed Jun 3 11:01:39 2020 +0100) - * 63f0e3e - Merge pull request #142 from elliottmurray/ssl_verify (Elliott Murray, Wed Jun 3 09:50:10 2020 +0100) - * cd43bd0 - Removed coverage (Elliott Murray, Tue Jun 2 21:41:52 2020 +0100) - * 30e6f86 - Fixed flake (Elliott Murray, Tue Jun 2 21:32:01 2020 +0100) - * 1a11320 - Fix unit tests (Elliott Murray, Tue Jun 2 21:29:56 2020 +0100) - * 353d054 - travis code coverage (Elliott Murray, Tue Jun 2 21:14:37 2020 +0100) - * c08babd - Fixing unit tests command in tox and travis (Elliott Murray, Tue Jun 2 18:30:10 2020 +0100) - * 157676c - Allowed https communication to mock. Didnt fix tests (Elliott Murray, Tue Jun 2 17:47:08 2020 +0100) - * 60c9f5a - Fix deploy to pypi2 (Elliott Murray, Fri May 22 13:50:41 2020 +0100) - * e2c7e4e - Fix deploy to pypi (Elliott Murray, Fri May 22 13:41:27 2020 +0100) - - ### v1.0.0 - * 2c6e4eb - Releasing version 1.0.0 (Elliott Murray, Fri May 22 13:30:49 2020 +0100) - * c68ccb7 - Merge pull request #140 from elliottmurray/python2_deprecate (Elliott Murray, Fri May 22 13:29:38 2020 +0100) - * 8bc6d48 - Release script to make life a bit easier (Elliott Murray, Thu May 21 12:32:27 2020 +0100) - * a845f71 - Removed 2.x support (Elliott Murray, Thu May 21 12:19:16 2020 +0100) - * 562e047 - Merge pull request #138 from pyasi/pyasi_add_matcher_regexes (Elliott Murray, Fri May 15 14:10:28 2020 +0100) - * db39d87 - remove virtualenv (Peter Yasi, Fri May 15 09:02:01 2020 -0400) - * cccd30a - Add Format to the standard pact package (Peter Yasi, Fri May 15 08:55:57 2020 -0400) - * b78ac6d - Merge branch 'master' into pyasi_add_matcher_regexes (Peter Yasi, Fri May 15 08:13:30 2020 -0400) - * 35dfa0d - add enum34 a a dep for py27-install (Peter Yasi, Thu May 14 20:52:09 2020 -0400) - * 1fcc6c1 - Pydocstyle fixes, will still need fix for no enum in 2.7 (Peter Yasi, Thu May 14 19:49:23 2020 -0400) - * fe068e5 - Add examples to e2e tests (Peter Yasi, Thu May 14 19:07:31 2020 -0400) - * 5aaa82f - README documentation (Peter Yasi, Thu May 14 18:46:42 2020 -0400) - * 0d588f7 - pydocs and formatting (Peter Yasi, Thu May 14 18:19:32 2020 -0400) - * a21118c - Use raw strings to avoid deprecated escape sequence (Peter Yasi, Thu May 14 08:54:01 2020 -0400) - * 715d10f - Initial implementation with example unit tests (Peter Yasi, Thu May 14 00:00:30 2020 -0400) - - -### 0.22.0 - * d112a4a - Merge pull request #134 from elliottmurray/multiple-custom-provider-header (Elliott Murray, Mon May 11 16:32:49 2020 +0100) - * 58f8e6b - Fix some style issues (Elliott Murray, Wed Apr 29 12:35:00 2020 +0100) - * bf9bc2d - Added multiple click options for custom headers (Elliott Murray, Tue Apr 28 18:14:02 2020 +0100) - * 254ffc5 - Merge pull request #130 from elliottmurray/examples (Elliott Murray, Sat May 9 18:02:46 2020 +0100) - * 3898aee - Created examples folder (Elliott Murray, Thu Apr 2 14:03:17 2020 +0100) - * b859443 - Merge pull request #129 from elliottmurray/docker (Elliott Murray, Sat May 9 17:54:01 2020 +0100) - * 9b83da7 - Added bash to containers (Elliott Murray, Fri Apr 10 11:52:43 2020 +0100) - * 73db8fc - Remove subprocess requirement (Elliott Murray, Fri Apr 10 11:32:38 2020 +0100) - * f3315a1 - Added 38 and created build helper script (Elliott Murray, Wed Apr 1 17:11:06 2020 +0100) - * e7743de - Some readme and python37 (Elliott Murray, Wed Apr 1 14:25:20 2020 +0100) - * 515aeb2 - Tweaked the run script (Elliott Murray, Wed Apr 1 12:57:02 2020 +0100) - * 5a6acaf - Merge pull request #131 from elliottmurray/python38 (Elliott Murray, Sat May 9 17:47:13 2020 +0100) - * bb921eb - Updated to 3.8 (Elliott Murray, Sat Apr 4 16:39:18 2020 +0100) - * 12108c4 - Merge pull request #132 from pyasi/pyasi_test_refactor (Elliott Murray, Sat May 9 17:33:59 2020 +0100) - * 48ad173 - Merge pull request #135 from m-aciek/master (Elliott Murray, Sat May 9 17:21:52 2020 +0100) - * 6948482 - Merge pull request #136 from pact-foundation/chore/upgrade-to-pact-ruby-standalone-1-84-0 (Elliott Murray, Sat May 9 15:13:07 2020 +0100) - * 14603ac - feat: update standalone to 1.84.0 (Beth Skurrie, Sat May 2 09:43:30 2020 +1000) - * 410caf1 - chore: add script to create a PR to update the pact-ruby-standalone version (Beth Skurrie, Sat May 2 09:42:55 2020 +1000) - * b5af1fc - Fix missing normalization of consumer name while publishing pact (Maciej Olko, Thu Apr 30 08:50:17 2020 +0200) - * 5785782 - Move tests to standard tests dir (Peter Yasi, Fri Apr 17 14:03:04 2020 -0400) - * 88ea23d - docs: update RELEASING.md (Beth Skurrie, Tue Feb 18 10:46:11 2020 +1100) - -### 0.21.0 -* 6352dda - feat: update to pact-ruby-standalone-1.79.0 (#127) (Beth Skurrie, Tue Feb 18 10:25:59 2020 +1100) -* 758d6ea - Converting to kwargs (Elliott Murray, Sat Feb 1 16:24:49 2020 +1100) -* 1388b8f - feat: support using environment variables to set pact broker configuration (mikahjc, Wed Jan 29 17:52:33 2020 -0700) -* ec7ff99 - Make verify tests compatible with Click v7.x (mikahjc, Tue Jun 11 16:37:13 2019 -0600) -* 5dcb56c - Add broker_token parameter for authentication (mikahjc, Tue Jun 11 16:16:46 2019 -0600) -* 1bdfb42 - Integrate the Ruby pact broker client to allow for automatic publishing of pacts (mikahjc, Tue Jun 11 11:13:18 2019 -0600) - -### 0.20.0 - * 978d9f3 - fix typo (Jingjing Duan, Wed May 24 15:48:43 2017 -0700) - * 4ede7d5 - Merge pull request #117 from dlmiddlecote/feature/expose-more-options (Matt Fellows, Fri Jan 17 10:00:56 2020 +1100) - * 73ae8d2 - Update docs (Daniel Middlecote, Tue Jan 14 22:11:40 2020 +0000) - * 2bffe5e - Simple test case (Daniel Middlecote, Tue Jan 14 22:11:25 2020 +0000) - * 3ba51b5 - Add --broker-token support (Daniel Middlecote, Tue Jan 14 22:04:39 2020 +0000) - * d3a8ba6 - Update pact-ruby-standalone (Daniel Middlecote, Tue Jan 14 21:45:01 2020 +0000) - * 0cbb9d4 - Merge pull request #115 from ejrb/patch-1 (Matthew Balvanz, Sat Dec 14 20:49:56 2019 -0600) - * 0c85502 - match platforms like 'macOS-*' to osx suffix (ejrb, Mon Dec 9 11:13:19 2019 +0000) - * 9a0eaa7 - Merge pull request #109 from pact-foundation/dependabot/pip/flask-1.0 (Matthew Balvanz, Mon Sep 30 21:35:20 2019 -0500) - * 6f70a28 - Bump flask from 0.11.1 to 1.0 (dependabot[bot], Sat Sep 28 19:20:11 2019 +0000) - -### 0.19.0 - * fed5fba - Start testing in Python 3.7 (Matthew Balvanz, Sat Sep 28 15:18:17 2019 -0500) - * 19aa689 - Adjust tests to support click 2.0.0 to 7.0.0 (Matthew Balvanz, Sat Sep 28 15:04:53 2019 -0500) - * 9d4d6f3 - Merge pull request #94 from yangineer/optional_given (Matthew Balvanz, Sat Sep 28 14:52:49 2019 -0500) - * b286b30 - Merge branch 'master' into optional_given (Matthew Balvanz, Sat Sep 28 14:50:02 2019 -0500) - * 68e792a - Merge pull request #93 from francoiscampbell/pass_file_write_mode (Matthew Balvanz, Sat Sep 28 14:18:59 2019 -0500) - * 8927df6 - Updated the tests for Click v7 (Yang Wang, Sat Oct 20 00:34:37 2018 -0400) - * 125a1de - Changed given to be optional (Yang Wang, Sat Oct 20 00:27:07 2018 -0400) - * 68527e0 - max out click at 6.7 because 7.0 fails tests (Francois Campbell, Thu Oct 4 10:57:13 2018 -0400) - * 2452f42 - update tests (Francois Campbell, Thu Oct 4 10:56:42 2018 -0400) - * 1116601 - Add param docs (Francois Campbell, Thu Oct 4 10:04:50 2018 -0400) - * 48a7591 - Pass file_write_mode from Consumer to Pact (Francois Campbell, Thu Oct 4 10:00:37 2018 -0400) - * 6d39609 - Merge pull request #91 from szekar1/small_updates_to_docs (Matthew Balvanz, Fri Aug 24 13:49:05 2018 -0500) - * a5c8146 - Update README.md (bvccaneer, Fri Aug 24 19:23:26 2018 +0200) - * 4d40485 - adding documentation around #52 and fixing dead link for Matching docs (szekar1, Fri Aug 24 19:19:10 2018 +0200) - -### 0.18.0 - * 4e8bb85 - Upgrade pact-ruby-standalone (Matthew Balvanz, Tue Aug 21 08:56:53 2018 -0500) - * 8a44feb - chore(docs): update contact information (Matt Fellows, Thu Aug 2 17:18:43 2018 +1000) - -### 0.17.0 - * cf5d5bc - Merge pull request #87 from acabelloj/custom-provider-header-support (Matthew Balvanz, Fri Jul 20 22:27:33 2018 -0500) - * cc61427 - Fixes #83 The verifier always returns exit code 0 (Matthew Balvanz, Fri Jul 20 22:08:26 2018 -0500) - * 239da1c - Remove Python 3.3 from Travis builds (Matthew Balvanz, Wed Jul 4 10:39:12 2018 -0500) - * 273b3fd - Remove Python 3.3 testing (Matthew Balvanz, Wed Jul 4 10:36:01 2018 -0500) - * 01c6763 - Add support to custom provider header (Alejandro Cabello Jiménez, Fri Jun 1 11:40:32 2018 +0200) - -### 0.16.1 - * eecbb60 - Merge pull request #79 from shahha/fix-stopping-mock-service-on-windows (Matthew Balvanz, Fri Mar 16 08:45:19 2018 -0500) - * 4115264 - Added windows specific code to check if mock service is stopped. (Hardik Shah, Wed Mar 7 10:44:33 2018 +1100) - -### 0.16.0 - * 30af240 - Merge pull request #78 from pact-foundation/standalone-1-29-2 (Matthew Balvanz☃, Fri Mar 2 22:05:12 2018 -0600) - * d428951 - Update to pact-ruby-standalone 1.29.2 (Matthew Balvanz, Fri Mar 2 21:59:08 2018 -0600) - -### 0.15.0 - * eb925c3 - Merge pull request #77 from pact-foundation/standalone-1-9-1 (Matthew Balvanz☃, Fri Mar 2 21:22:35 2018 -0600) - * 2a2dcd1 - Upgrade to pact-ruby-standalone 1.9.1 (Matthew Balvanz, Fri Mar 2 21:18:25 2018 -0600) - * 53545be - Merge pull request #72 from fabianbuechler/reduce-server-start-timeout (Matthew Balvanz☃, Fri Mar 2 21:04:03 2018 -0600) - * b782e43 - Merge pull request #76 from pact-foundation/hide-ruby-stacks (Matthew Balvanz☃, Fri Mar 2 21:03:14 2018 -0600) - * 589224a - Hide Ruby stack traces by default (Matthew Balvanz, Fri Mar 2 20:56:59 2018 -0600) - * e952b37 - Reduce timeout in _wait_for_server_start to 25s (Fabian Büchler, Fri Feb 9 11:04:01 2018 +0100) - -### 0.14.0 - * 3070638 - Merge pull request #71 from pact-foundation/update-standalone-1-9-0 (Matthew Balvanz, Sat Feb 3 23:25:37 2018 -0600) - * 475703c - Resolves #58: Update to pact-ruby-standalone 1.9.0 (Matthew Balvanz, Sat Feb 3 23:12:22 2018 -0600) - -### 0.13.0 - * 3316743 - Merge pull request #69 from jawu/#52-helper-function-for-assertion-with-matchers (Matthew Balvanz, Sat Jan 20 16:43:56 2018 -0600) - * ae7f333 - Merge pull request #70 from bethesque/issues/pact-provider-verifier-19 (Matthew Balvanz, Sat Jan 20 16:40:31 2018 -0600) - * 81597d9 - docs: remove reference to v3 pact in provider-states-setup-url (Beth Skurrie, Tue Jan 9 12:27:18 2018 +1100) - * 8bedfd4 - removed local files (Janneck Wullschleger, Wed Dec 20 05:12:08 2017 +0100) - * 5ab2648 - solves #52 added get_generated_values to resolve Mathers to their generated value for assertion (Janneck Wullschleger, Wed Dec 20 05:06:33 2017 +0100) - -### 0.12.0 - * 149dfc7 - Merge pull request #67 from jawu/enable-possibility-to-use-mathers-in-path (Matthew Balvanz, Sun Dec 17 10:32:34 2017 -0600) - * fb97d2f - fixed doc string of Request (Janneck Wullschleger, Sat Dec 16 20:44:11 2017 +0100) - * c2c24cc - adjusted doc string of Request calss to allow str and Matcher as path parameter (Janneck Wullschleger, Sat Dec 16 20:40:35 2017 +0100) - * 697a6a2 - fixed port parameter in e2e test for python 2.7 (Janneck Wullschleger, Thu Dec 14 15:08:05 2017 +0100) - * ca2eb92 - added from_term call in Request constructor to process path property for json transport (Janneck Wullschleger, Thu Dec 14 14:45:11 2017 +0100) - -### 0.11.0 - * ad69039 - Merge pull request #63 from pact-foundation/run-specific-interactions (Matthew Balvanz, Sun Dec 17 09:53:35 2017 -0600) - * eb63864 - Output a rerun command when a verification fails (Matthew Balvanz, Sun Nov 19 20:44:06 2017 -0600) - * 7c7bc7d - Merge pull request #62 from dhoomakethu/master (Matthew Balvanz, Sun Nov 19 19:53:48 2017 -0600) - * c27a7a9 - #62 Fix flake8 issues 2 (sanjay, Sun Nov 19 11:18:15 2017 +0530) - * 382c46c - #62 fix flake issues (sanjay, Sun Nov 19 11:13:58 2017 +0530) - * cdcc85d - Add support to publish verification result to pact broker (sanjay, Tue Oct 31 12:41:52 2017 +0530) - * c1a5402 - Merge pull request #2 from pact-foundation/master (dhoomakethu, Tue Oct 31 12:15:53 2017 +0530) - * b91f6c3 - Merge pull request #1 from pact-foundation/master (dhoomakethu, Mon Aug 21 12:36:15 2017 +0530) - -### 0.10.0 - * 821671e - Merge pull request #53 from pact-foundation/verify-directories (Matthew Balvanz, Sat Nov 18 23:26:05 2017 -0600) - * 8291bb7 - Resolve #22: --pact-url accepts directories (Matthew Balvanz, Sat Oct 7 11:35:37 2017 -0500) - -### 0.9.0 - * 735aa87 - Set new project minimum requirements (Matthew Balvanz, Sun Oct 22 16:30:12 2017 -0500) - * 295f17c - Merge pull request #60 from ftobia/requirements (Matthew Balvanz, Sun Oct 22 16:09:59 2017 -0500) - * 1dc72da - Merge pull request #48 from bassdread/allow-later-versions-of-requests (Matthew Balvanz, Sun Oct 22 16:09:39 2017 -0500) - * 3265b45 - add suggestion (Chris Hannam, Fri Oct 20 09:33:05 2017 +0100) - * 33504a6 - Resolve #51 verify outputs text instead of bytes (Matthew Balvanz, Thu Oct 19 21:28:39 2017 -0500) - * 51dcda3 - Merge pull request #57 from jceplaras/fix-e2e-test-incorrect-number-of-arg (Matthew Balvanz, Thu Oct 19 20:57:49 2017 -0500) - * 1a4d136 - Relax version requirements in setup.py (vs requirements.txt) (ftobia, Fri Oct 13 19:42:46 2017 -0400) - * 8ece1d6 - Fix incorrect indent on test_incorrect_number_of_arguments on test_e2e (James Plaras, Fri Oct 13 12:54:56 2017 +0800) - * 5f8257b - Resolve #50: Note which version of the Pact specification is supported (Matthew Balvanz, Sat Oct 7 14:05:26 2017 -0500) - * e728301 - Resolve #45: Document request query parameter (Matthew Balvanz, Sat Oct 7 13:58:07 2017 -0500) - * 5de7200 - Merge pull request #49 from pact-foundation/rename-somethinglike (Matt Fellows, Wed Oct 4 22:36:21 2017 +1100) - * d73aa1c - Resolve #43: Rename SomethingLike to Like (Matthew Balvanz, Mon Sep 4 15:49:13 2017 -0500) - * a07c8b6 - Merge pull request #46 from bassdread/fix-setup-url-name (Matthew Balvanz, Mon Sep 4 15:44:45 2017 -0500) - * b5e1f95 - allow later versions of requests (Chris Hannam, Tue Aug 29 13:38:42 2017 +0100) - * 08fe123 - make setup-url name format match above reference (Chris Hannam, Fri Aug 25 11:03:35 2017 +0100) - -### 0.8.0 - * edb6c72 - Merge pull request #41 from pact-foundation/fix-running-on-windows (Matthew Balvanz, Thu Aug 10 21:39:27 2017 -0500) - * 244fff1 - Merge pull request #42 from pact-foundation/deprecate-provider-states-url (Matthew Balvanz, Thu Aug 10 21:38:44 2017 -0500) - * 447b8bb - Resolve #17: Deprecate --provider-states-url (Matthew Balvanz, Sat Jul 29 11:53:05 2017 -0500) - * 4661406 - Move to using the `service` command with pact-mock-service (Matthew Balvanz, Sat Jul 29 10:00:47 2017 -0500) - * 04107db - Remove the PyPi server declaration to use the defaults (Matthew Balvanz, Sun Jul 16 09:05:30 2017 -0500) - -### v0.7.0 - * 223ea76 - Merge pull request #32 from SimKev2/pacturls (Matthew Balvanz, Sun Jul 16 08:41:14 2017 -0500) - * e382eb4 - Add tests for #36 SomethingLike not supporting Terms (Matthew Balvanz, Sun Jul 16 08:36:58 2017 -0500) - * 05b4d70 - Merge pull request #37 from jeanbaptistepriez/fix-somethinglike (Matthew Balvanz, Sun Jul 16 08:30:28 2017 -0500) - * 29a2518 - Fix json generation of SomethingLike (https://github.com/pact-foundation/pact-python/issues/36) (jean-baptiste.priez, Wed Jul 12 20:01:58 2017 +0200) - * b6e1a8b - Issue: Cannot supply multiple files to pact-verifier - PR: Added deprecation warning instead of making api-breaking change (simkev2, Sat Jun 24 20:05:05 2017 -0500) - * 17aa15b - Issue: Cannot supply multiple files to pact-verifier - Updated '--pact-urls' to be a single comma separated string argument - Added '--pact-url' which can be specified multiple times (simkev2, Sat Jun 24 12:57:51 2017 -0500) - * 65b493d - Merge pull request #33 from bethesque/reamde (Matthew Balvanz, Tue Jun 27 08:58:08 2017 -0500) - * f5a5958 - Update README.md (Beth Skurrie, Sun Jun 25 10:37:03 2017 +1000) - -### v0.6.2 -* 69caa40 - Merge pull request #35 from pact-foundation/fix-broker-credentials (Matt Fellows, Tue Jun 27 20:49:35 2017 +1000) -* d60f37f - Fix the use of broker credentials (Matthew Balvanz, Mon Jun 26 21:14:53 2017 -0500) - -### v0.6.1 -* 14968ea - Merge pull request #34 from hartror/rh_version_fix (Matthew Balvanz, Mon Jun 26 20:23:29 2017 -0500) -* aca520f - pydocstyle is fussy, should have run it before pushing (Rory Hart, Sun Jun 25 20:11:26 2017 +1000) -* b70103c - Added docstring for __version__.py (Rory Hart, Sun Jun 25 20:08:50 2017 +1000) -* 2076e34 - Disabled flake8 F401 for __version__ import (Rory Hart, Sun Jun 25 20:05:24 2017 +1000) -* 2912e07 - Version in setup.py reading __version__.py directly (Rory Hart, Sun Jun 25 19:40:08 2017 +1000) -* d137a21 - Split tox environments into test & install to replicate installation issue #31 (Rory Hart, Sun Jun 25 19:16:57 2017 +1000) -* f549ddf - Merge pull request #30 from bethesque/contributing (Matthew Balvanz, Sat Jun 24 12:43:30 2017 -0500) -* 1f19a0e - Update CONTRIBUTING.md (Beth Skurrie, Thu Jun 22 08:51:35 2017 +1000) -* 3198817 - Update CONTRIBUTING.md (Beth Skurrie, Thu Jun 22 08:36:57 2017 +1000) -* 7a08bb2 - Update CONTRIBUTING.md (Beth Skurrie, Thu Jun 22 08:35:27 2017 +1000) - -### v0.6.0 -* 10aaaf6 - Merge pull request #27 from pact-foundation/download-pre-package-mock-service-and-verifier (Matthew Balvanz, Tue Jun 20 21:51:40 2017 -0500) -* a9b991b - Update to pact-ruby-standalone 1.0.0 (Matthew Balvanz, Mon Jun 19 10:17:09 2017 -0500) -* ab43c8b - Switch to installing the packages from pact-ruby-standalone (Matthew Balvanz, Wed May 31 21:00:51 2017 -0500) -* db3e7c3 - Use the compiled Ruby applications from pact-mock-service and pact-provider-verifier (Matthew Balvanz, Mon May 29 22:18:47 2017 -0500) - -### v0.5.0 -* c085a01 - Merge pull request #26 from AnObfuscator/stub-multiple-requests (Matthew Balvanz, Mon Jun 19 09:14:51 2017 -0500) -* 22c0272 - Add support for stubbing multiple requests at the same time (AnObfuscator, Fri Jun 16 23:18:01 2017 -0500) - -### v0.4.1 -* 66cf151 - Add RELEASING.md closes #18 (Matthew Balvanz, Tue May 30 22:41:06 2017 -0500) -* 3f61c91 - Add support for request bodies that are False in Python (Matthew Balvanz, Tue May 30 21:57:46 2017 -0500) -* a39c62f - Merge pull request #19 from ftobia/patch-1 (Matthew Balvanz, Tue May 30 21:42:41 2017 -0500) -* 95aa93a - Allow falsy responses (e.g. 0 not as a string). (Frank Tobia, Mon May 29 19:22:13 2017 -0400) -* dd3c703 - Merge pull request #16 from jduan/master (Jose Salvatierra, Thu May 25 09:20:10 2017 +0100) -* 978d9f3 - fix typo (Jingjing Duan, Wed May 24 15:48:43 2017 -0700) - -### v0.4.0 -* 8bec271 - Setup Travis CI to publish to PyPi (Matthew Balvanz, Wed May 24 16:51:05 2017 -0500) -* d67a015 - Merge pull request #14 from pact-foundation/verify-pacts (Matthew Balvanz, Wed May 24 16:46:49 2017 -0500) -* 78bd029 - Add CONTRIBUTING.md file resolves #4 (Matthew Balvanz, Mon May 22 20:41:09 2017 -0500) -* d7c32c4 - Repository badges (Matthew Balvanz, Mon May 22 20:22:14 2017 -0500) -* 97122f1 - Merge pull request #13 from pact-foundation/update-developer-documentation (Matthew Balvanz, Sat May 20 20:55:06 2017 -0500) -* ea015eb - Increment project to v0.4.0 (Matthew Balvanz, Fri May 19 23:46:00 2017 -0500) -* 51eb338 - Command line application for verifying pacts (Matthew Balvanz, Fri May 19 22:24:06 2017 -0500) -* 4b0bbd7 - Update the developer instructions (Matthew Balvanz, Fri May 19 22:05:54 2017 -0500) - -### v0.3.0 -* 3130f9a - Merge pull request #11 from pact-foundation/update-mock-service (Matthew Balvanz, Sun May 14 09:03:43 2017 -0500) -* 9b20d36 - Updated Versions of Pact Ruby applications (Matthew Balvanz, Sat May 13 09:43:44 2017 -0500) - -### v0.2.0 -* 140f583 - Merge pull request #8 from pact-foundation/manage-mock-service (Matthew Balvanz, Sat May 13 09:18:40 2017 -0500) -* 5994c3a - pact-python manages the mock service for the user (Matthew Balvanz, Tue May 9 21:58:08 2017 -0500) -* 4bf7b8b - pact-python manages the mock service for the user (Matthew Balvanz, Mon May 1 20:12:53 2017 -0500) -* 0a278af - pact-python manages the mock service for the user (Matthew Balvanz, Tue Apr 18 21:23:18 2017 -0500) -* fd68b41 - Merge pull request #2 from pact-foundation/package-ruby-apps (Matthew Balvanz, Sat Apr 22 10:55:48 2017 -0500) -* 75a96dc - Package the Ruby Mock Service and Verifier (Matthew Balvanz, Tue Apr 4 23:14:11 2017 -0500) - -### v0.1.0 -* 189c647 - Merge pull request #3 from pact-foundation/travis-ci (Jose Salvatierra, Fri Apr 7 21:40:02 2017 +0100) -* 559efb8 - Add Travis CI build (Matthew Balvanz, Fri Apr 7 11:12:01 2017 -0500) -* 8f074a0 - Merge pull request #1 from pact-foundation/initial-framework (Matthew Balvanz, Fri Apr 7 09:55:34 2017 -0500) -* f5caf9c - Initial pact-python implementation (Matthew Balvanz, Mon Apr 3 09:16:59 2017 -0500) -* bfb8380 - Initial pact-python implementation (Matthew Balvanz, Thu Mar 30 20:41:05 2017 -0500) +# Changelog + +All notable changes to this project will be documented in this file. + + + + + +## [pact-python/3.4.0] _2026-05-04_ + +### 🚀 Features + +- Add external reference dsl + +### Contributors + +- @rholshausen +- @JP-Ellis + +## [pact-python/3.3.1] _2026-04-22_ + +### 🐛 Bug Fixes + +- Avoid rare port clash + +### ⚙️ Miscellaneous Tasks + +- Simplify find_free_port +- Replace pre-commit with prek + +### Contributors + +- @JP-Ellis + +## [pact-python/3.3.0] _2026-04-17_ + +### 🚀 Features + +- Add xml matching + + A new `pact.xml` module provides builder functions for constructing XML request and response bodies with embedded Pact matchers. Use `xml.element()` to describe the XML structure and attach matchers where needed, then wrap the result with `xml.body()` before passing it to `with_body(..., content_type="application/xml")`: + + ```python + from pact import match, xml + + response = xml.body( + xml.element( + "user", + xml.element("id", match.int(123)), + xml.element("name", match.str("Alice")), + ) + ) + interaction.with_body(response, content_type="application/xml") + ``` + + Repeating elements are supported via `.each(min=1, examples=2)` on any `XmlElement`. Attributes (including namespace declarations) can be passed via the `attrs` keyword argument. +- Allow iteration over all interactions +- Use common `PactInteraction` type +- Can toggle follow redirects +- Allow plugin loading delay + +### 📚 Documentation + +- Update changelog for pact-python/3.2.1 +- _(examples)_ Add http+xml example +- Update xml example to use new matcher +- _(examples)_ Add service consumer/provider HTTP example + +### ⚙️ Miscellaneous Tasks + +- _(ci)_ Re-enable 3.14 tests +- Upgrade stable python version +- Add .worktrees to .gitignore +- _(ci)_ Reduce ci usage +- _(ci)_ Downgrade stable python version +- _(ci)_ Remove unused workflows +- Remove versioningit, switch to static version in pyproject.toml +- Add release script +- Minor update to cliff config +- Authenticate gh api calls +- Remove release label +- Replace taplo with tombi +- _(ci)_ Have wheel target 310 +- _(ci)_ Avoid most of CI on draft PRs +- Fix hatch env workspaces +- Remove connect test + +### � Other + +- Fix coverage upload overwrite and add example coverage + +### Contributors + +- @JP-Ellis +- @adityagiri3600 +- @benaduo +- @Nikhil172913832 + +## [pact-python/3.2.1] _2025-12-10_ + +### 📚 Documentation + +- Update changelog for pact-python/3.2.0 +- Fix internal references +- Add v3 blog post +- Fix partial url highlight +- Fix tooltips in code +- Remove redundant header from blog post + +### ⚙️ Miscellaneous Tasks + +- _(ci)_ Use strict docs building +- Switch to versioningit + +### Contributors + +- @JP-Ellis + +## [pact-python/3.2.0] _2025-12-02_ + +### 🚀 Features + +- Add consumer_version method +- Add content type matcher +- Add 'and' matcher + +### 🐛 Bug Fixes + +- Use correct matching rule serialisation + +### 📚 Documentation + +- Update changelog for pact-python/3.1.0 +- Add agents.md +- Update configuration +- Add logging documentation +- Add multipart/form-data matching rule example +- Add consumer_version + +### ⚙️ Miscellaneous Tasks + +- Add llm instructions +- Update non-compliant docstrings and types +- Upgrade pymdownx extensions +- Set telemetry environment variables +- _(docs)_ Api docs link on pact-python site is case sensitive +- Fix json schema url +- _(tests)_ Fix skipped tests on windows +- _(ci)_ Update macos runners +- Remove unused pytest config +- Remove ruff sub-configs +- Switch to markdownlint-cli2 +- Rerun flaky tests +- Remove unused function +- Don't except AssertionError +- _(devcontainer)_ Add multi-arch development container support + +### Contributors + +- @Nikhil172913832 +- @JP-Ellis +- @YOU54F +- @Copilot + +## [pact-python/3.1.0] _2025-10-07_ + +### 🐛 Bug Fixes + +- [**breaking**] Replace v2 extra with compat-v2 + > Installing Pact Python with v2 compatibility requires `pip install 'pact-python[compat-v2]'`, and the old `pip install 'pact-python[v2]'` is no longer supported. + +### 📚 Documentation + +- Update changelogs + +### ⚙️ Miscellaneous Tasks + +- _(ci)_ Add area-core label +- _(ci)_ Fix labels workflow permissions +- Remove no longer relevant todo +- _(docs)_ Use normalized project url keys + +### Contributors + +- @JP-Ellis + +## [pact-python/3.0.1] _2025-10-06_ + +### 📚 Documentation + +- Update changelog for pact-python/3.0.0 + +### ⚙️ Miscellaneous Tasks + +- Drop cffi dependency +- _(ci)_ Fix publish step + +### Contributors + +- @JP-Ellis + +## [pact-python/3.0.0] _2025-10-06_ + +### 🚀 Features + +- [**breaking**] Default to v4 specification + > Pact instances default to version 4 of the Pact specification (previously used version 3). This should be mostly backwards compatible, but can be reverted by using `with_specification("V3")`. +- Populate broker source from env + +### 🚜 Refactor + +- _(ci)_ If statement + +### 🎨 Styling + +- _(tests)_ Add sections + +### 📚 Documentation + +- Update changelog for pact-python/3.0.0a1 +- Add mascot +- Give mascot outline +- Set mascot width and height +- _(examples)_ Add requests and fastapi +- Generate llms.txt +- Update mkdocs material features +- Fix CI badge links +- Update matcher docs +- Improve matchers +- Improve generators +- Update for v3 and add migration guide + +### ⚙️ Miscellaneous Tasks + +- _(ci)_ Remove spelling check +- _(examples)_ Minor improvements +- Store hatch venv in .venv +- Update mismatch repr +- Save mismatches before exiting the server +- _(examples)_ Remove old http example +- Fix sub-project git cliff config +- Hide import from traceback +- Fix flask integer coercion +- Add v3 matching rules test +- Add v4 matching rules tests +- _(ci)_ Add publish as completion dependency +- _(tests)_ Add generators to interaction defn +- _(tests)_ Test v3 generators +- _(test)_ Add v4 generators tests +- Re-add pytest rerunfailrure +- _(tests)_ Add v3 http generators +- Prefer prek over pre-commit +- Disable reruns in vscode +- _(ci)_ Fix prek caching +- _(ci)_ Generate junit xml files +- Move mascot file out of root +- Update uuid format names +- Fix import warning +- Make Unset falsey +- [**breaking**] Rename abstract matcher class + > The abstract `pact.match.Matcher` class has been renamed to `pact.match.AbstractMatcher`. +- [**breaking**] Rename abstract generator + > The abstract `pact.generate.Generator` class has been renamed to `pact.generate.AbstractGenerator`. +- Clarify explanation of given +- [**breaking**] Drop python 3.9 add 3.14 + > Python 3.9 is no longer supported. +- _(ci)_ Disable 3.14 tests using pydantic + +### Contributors + +- @JP-Ellis + +## [pact-python/3.0.0a1] _2025-08-12_ + +### 🚀 Features + +- Create pact-python-cli package +- _(cli)_ Build abi-agnostic wheels +- _(ffi)_ Add standalone ffi package +- _(v3)_ [**breaking**] Remove pact.v3.ffi module + > `pact.v3.ffi` is removed, and to be replaced by `pact_ffi`. That is, `pact.v3.ffi.$fn` should be replaced by `pact_ffi.$fn`. +- [**breaking**] Prepare for v3 release + > This prepares for version 3. Pact Python v2 will be still accessible under `pact.v2` and all imports should be appropriate renamed. Everyone is encouraged to migrate to Pact Python v3. +- [**breaking**] Simplify `given` + > The signature of `Interaction.given` has been updated. The following changes are required: - Change `given("state", key="user_id", value=123)` to `given("state", user_id=123)`. This can take an arbitrary number of keyword arguments. If the key is not a valid Python keyword argument, use the dictionary input below. - Change `given("state", parameters={"user_id": 123})` to `given("state", {"user_id": 123})`. +- [**breaking**] Deserialize metadata values + > As the metadata values are now deserialised, the type of the metadata values may change. For example, setting metadata `user_id=123` will now pass `{"user_id": 123}` through to the function handler. Previously, this would have been `{"user_id": "123"}`. + +### 🐛 Bug Fixes + +- Matcher type variance +- With metadata function signature +- [**breaking**] Use correct datetime default format + > If you relied on the previous default (undocumented) behaviour, prefer specifying the format explicitly now: `match.datetime(regex="%Y-%m-%dT%H:%M:%S.%f%z")`. +- Handle empty state callback +- _(verifier)_ [**breaking**] Propagate branch + > If a branch is set through either `set_publish_options` or `provider_branch`, the value will be saved and used as a default for both in subsequent calls. + +### 🚜 Refactor + +- Functional state handler + +### 📚 Documentation + +- Update changelog for v2.3.3 +- _(blog)_ Fix v3 references +- Fix v3 references +- V3 review +- Update git cliff configuration + +### ⚙️ Miscellaneous Tasks + +- Update pre-commit hooks +- Use the new `pact_cli` package +- Remove packaging of pact cli +- _(ci)_ Incorporate tests of pact cli +- _(ci)_ Use new `pact-python/*` tags +- _(ci)_ Add build cli pipeline +- Exclude hatch_build from mypy checks +- _(ci)_ Narrow token permissions +- Remove macosx deployment target +- _(ci)_ Fix cli publish permissions +- Properly extract tag version +- Update gitignore +- _(ci)_ Fix core package build +- Split out dependencies and tests +- _(ci)_ Update labels +- _(ci)_ Fix labels +- _(tests)_ Re-organise tests +- Fix bad copy-paste in tests +- Log exceptions from apply_args +- Improve logging from apply_args +- _(examples)_ Start examples overhaul +- _(ci)_ Use new examples +- Update protobuf examples +- _(ci)_ Cancel ci on PRs +- Add vscode settings and extensions +- Add envrc +- Replace yamlfix with yamlfmt +- Remove deptry config +- Support pre and post release tags +- Fix typo + +### Contributors + +- @JP-Ellis +- @kevinrvaz + +## [2.3.3] _2025-07-17_ + +### 🚀 Features + +- _(v3)_ Add will_respond_with for sync + +### 🐛 Bug Fixes + +- _(v3)_ Avoid error if there's no mismatch type + +### 📚 Documentation + +- _(examples)_ Add proto module documentation +- Add protobuf and grpc links + +### ⚙️ Miscellaneous Tasks + +- _(ci)_ Remove pre-commit cache restore key +- Update biome +- _(examples)_ Add protobuf example +- Add version stub file +- _(examples)_ Parametrize protobuf example +- _(ci)_ Update runners +- Split mypy calls + +### Contributors + +- @JP-Ellis + +## [2.3.2] _2025-05-05_ + +### 🚀 Features + +- _(v3)_ [**breaking**] Allow more flexible functional arguments + > The signature of functional arguments must form a subset of the `MessageProducerArgs` and `StateHandlerArgs` typed dictionaries. + +### 📚 Documentation + +- Replace commitizen with git cliff +- Update blog post +- Rename params -> parameters +- _(example)_ Elaborate on state handler + +### ⚙️ Miscellaneous Tasks + +- Update pre-commit hooks +- Update committed configuration +- Add taplo +- _(ci)_ Update ubuntu runners +- Reduce noise from taiki-e/install-action +- _(ci)_ Upload test results to codecov +- Add apply_arg utility +- _(tests)_ Use consistent return value +- _(test)_ Tweak type signature +- _(examples)_ Fix state handler args + +### Contributors + +- @JP-Ellis + +## [2.3.1] _2025-01-22_ + +### 🐛 Bug Fixes + +- _(v3)_ Defer setting pact broker source + +### Contributors + +- @JP-Ellis + +## [2.3.0] _2024-12-30_ + +### 🚀 Features + +- _(v3)_ Add message relay and callback servers +- _(v3)_ [**breaking**] Integrate message relay server + > The provider name must be given as an argument of the `Verifier` constructor, instead of the first argument of the `set_info` method. +- _(v3)_ [**breaking**] Add state handler server + > `set_state` has been renamed to `state_handler`. If using a URL still, the `body` keyword argument is now a _required_ parameter. +- _(v3)_ [**breaking**] Further simplify message interface + > `message_handler` signature has been changed and expanded. + +### 🎨 Styling + +- Lint +- Lint + +### 📚 Documentation + +- Fix minor typos +- _(blog)_ Add functional arguments post + +### ⚙️ Miscellaneous Tasks + +- Fix __url__ +- _(ci)_ Pin full version +- Add yamlfix +- Remove docker files and scripts +- Update biome version +- Rename master to main +- _(ci)_ Pin typos to version +- _(ci)_ Pin minor version of checkout action +- Silence unset default fixture loop scope +- _(ci)_ Replace pre-commit/action +- _(v3)_ [**breaking**] Remove unnecessary underscores + > The PactServer `__exit__` arguments no longer have leading underscores. This is typically handled by Python itself and therefore is unlikely to be a change for any user, unless the end user was calling the `__exit__` method explicitly _and_ using keyword arguments. +- _(v3)_ [**breaking**] Make util module private + > `pact.v3.util` has been renamed to `pact.v3._util` and is now private. +- _(ci)_ Upgrade macos-12 to macos-13 +- _(c)_ Specify full action version +- Add pytest-xdist +- _(ci)_ Remove condition on examples +- Update tests to use new message/state fns +- Adapt examples to use function handlers +- Move matchers test out of examples +- Adjust tests based on new implementation +- Remove dead code +- Fix compatibility with 3.9, 3.10 +- Add pytest-rerunfailures +- Fix windows compatibility +- _(ci)_ Automerge renovate PRs + +### Contributors + +- @JP-Ellis + +## [2.2.2] _2024-10-10_ + +### 🚀 Features + +- _(examples)_ Add post and delete +- Add matchable typevar +- Add strftime to java date format converter +- Add match aliases +- Add uuid matcher +- Add each key/value matchers +- Add ArrayContainsMatcher +- [**breaking**] Improve mismatch error + > The `srv.mismatches` is changed from a `list[dict[str, Any]]` to a `list[Mismatch]`. +- [**breaking**] Add Python 3.13, drop 3.8 + > Python 3.8 support dropped + +### 🐛 Bug Fixes + +- Missing typing arguments +- Incompatible override +- Kwargs typing +- Ensure matchers optionally use generators +- _(examples)_ Do not overwrite pact file on every test +- _(examples)_ Use wget for broker healthcheck +- _(examples)_ Correct URL for healthcheck +- _(examples)_ Do not publish postgres port +- Typing annotations +- ISO 8601 incompatibility + +### 🚜 Refactor + +- Prefer `|` over Optional and Union +- Rename matchers to match +- Split types into stub +- Matcher +- Rename generators to generate +- Generate module in style of match module +- Create pact.v3.types module +- Generators module +- Match module + +### 📚 Documentation + +- _(blog)_ Don't use footnote numbers +- _(blog)_ Add async message blog post +- Update example docs +- Add matcher module preamble +- Add module docstring + +### ⚙️ Miscellaneous Tasks + +- _(ci)_ Use pypi trusted publishing +- Fix typo in previous blog post +- _(ci)_ Update docs on push to master +- Regroup ruff in renovate +- Add extra checks +- Added v3 http interaction examples +- _(ci)_ Add codecov +- Refactor tests +- Prefer ABC over ABCMeta +- Re-organise match module +- Split stdlib and 3rd party types +- Silence a few mypy complaints +- Add pyi to editor config +- Add test for full ISO 8601 date +- Minor improvements to match.matcher +- Align generator with matcher +- Remove MatchableT +- Get test to run again +- Add boolean alias +- Fix compatibility with Python <= 3.9 +- Fix match tests +- Remove unused generalisation +- Use matchers in v3 examples +- Use native Python datetime object +- Adjust tests to use new Mismatch class +- Disable wait +- _(ci)_ Switch to uv fully +- _(ci)_ Disable docs workflow on tags +- _(ci)_ Tweak build conditions +- Disable pypy builds + +### Contributors + +- @JP-Ellis +- @individual-it +- @valkolovos +- @amit828as + +## [2.2.1] _2024-07-22_ + +### 🚀 Features + +- _(ffi)_ Upgrade ffi 0.4.21 +- _(v3)_ Add enum type aliases +- _(v3)_ Improve exception types +- _(v3)_ Remove deprecated messages iterator +- _(v3)_ Implement message verification +- _(v3)_ Add async message provider +- _(ffi)_ Upgrade ffi to 0.4.22 + +### 🐛 Bug Fixes + +- _(ffi)_ Use `with_binary_body` + +### 🚜 Refactor + +- _(v3)_ New interaction iterators +- _(tests)_ Make `_add_body` a method of Body +- _(tests)_ Move InteractionDefinition in own module + +### 📚 Documentation + +- _(CONTRIBUTING.md)_ Update installation steps +- Add additional code capabilities +- Add blog post about rust ffi +- _(ffi)_ Properly document exceptions +- Minor refinements +- _(example)_ Clarify purpose of fs interface + +### ⚙️ Miscellaneous Tasks + +- Group renovate updates +- Use uv to install packages +- _(v3)_ Re-export Pact and Verifier at root +- _(ffi)_ Disable private usage lint +- _(ffi)_ Implement AsynchronousMessage +- _(ffi)_ Implement Generator +- _(ffi)_ Implement MatchingRule +- _(ffi)_ Remove old message and message handle +- _(ffi)_ Implement MessageContents +- _(ffi)_ Implement MessageMetadataPair and Iterator +- _(ffi)_ Implement ProviderState and related +- _(ffi)_ Implement SynchronousHttp +- _(ffi)_ Implement SynchronousMessage +- _(ffi)_ Bump links to 0.4.21 +- _(tests)_ Implement v3/v4 consumer message compatibility suite +- _(examples)_ Add v3 message consumer examples +- Update GitHub templates +- _(examples)_ Add asynchronous message +- _(tests)_ Replace stderr with logger +- _(tests)_ Increase message shown by `truncate` +- Minor typing fix +- _(tests)_ Significant refactor of InteractionDefinition +- _(tests)_ Add v4 message provider compatibility suite +- _(tests)_ Skip windows tests +- _(ci)_ Disable windows arm wheels + +### � Other + +- Fix macos-latest +- Narrow when docs are built and published + +### Contributors + +- @JP-Ellis +- @valkolovos +- @qmg-drettie + +## [2.2.0] _2024-04-11_ + +### 🚀 Features + +- _(v3)_ Add verifier class +- _(v3)_ Add verbose mismatches +- Upgrade FFI to 0.4.19 + +### 🐛 Bug Fixes + +- Delay pytest 8.1 +- _(v3)_ Allow optional publish options +- _(v3)_ Strip embedded user/password from urls + +### 🚜 Refactor + +- _(tests)_ Move parse_headers/matching_rules out of class +- Remove relative imports + +### 📚 Documentation + +- Setup mkdocs +- Update README +- Rework mkdocs-gen-files scripts +- Ignore private python modules +- Overhaul readme +- Update v3 docs +- Fix links to docs/ +- Add social image support +- Add blog post about v2.2 + +### ⚙️ Miscellaneous Tasks + +- _(ci)_ Remove cirrus +- _(ffi)_ Implement verifier handle +- _(v3)_ Add basic verifier tests +- Unskip tests +- Fix missed s/test/devel-test/ +- _(v3)_ Improve body representation +- _(test)_ Improve test logging +- _(tests)_ Update log formatting +- _(test)_ Add state to interaction definition +- _(test)_ Adapt InteractionDefinition for provider +- _(test)_ Add serialize function +- _(test)_ Add provider utilities +- _(tests)_ Add v1 provider compatibility suite +- _(tests)_ Fixes for lower python versions +- _(tests)_ Re-enable warning check +- _(tests)_ Improve logging from provider +- _(test)_ Strip authentication from url +- _(tests)_ Use long-lived pact broker +- _(test)_ Apply a temporary diff to compatibility suite +- _(test)_ Fix misspelling in step name +- _(tests)_ Improve logging +- _(tests)_ Allow multiple states with parameters +- _(tests)_ Implement http provider compatibility suite +- _(tests)_ Fix compatibility with py38 +- _(docs)_ Update emoji indices/generators +- _(docs)_ Fix typos +- _(docs)_ Enforce fenced code blocks +- _(docs)_ Minor fixes in examples/ +- Remove redundant __all__ +- _(docs)_ Update examples/readme.md +- _(ci)_ Update environment variables +- _(docs)_ Only publish from master +- _(test)_ Disable failing tests on windows + +### Contributors + +- @JP-Ellis +- @JosephBJoyce + +## [2.1.3] _2024-03-07_ + +### 🐛 Bug Fixes + +- Avoid wheel bloat + +### 📚 Documentation + +- Fix repository link typo +- Fix links to `CONTRIBUTING.md` + +### ⚙️ Miscellaneous Tasks + +- _(ci)_ Fix pypy before-build +- _(ci)_ Pin os to older versions +- _(ci)_ Set osx deployment target +- _(ci)_ Replace hatch clean with rm +- _(ci)_ Update concurrency group +- _(ci)_ Adapt before-build for windows + +### Contributors + +- @JP-Ellis + +## [2.1.2] _2024-03-05_ + +### 🚀 Features + +- _(v3)_ Add v3.ffi module +- _(v3)_ Implement pact class +- _(v3)_ Implement interaction methods +- _(ffi)_ Add OwnedString class +- _(v3)_ Implement Pact Handle methods +- _(v3)_ Add mock server mismatches +- _(v3)_ Implement server log methods +- Add python 3.12 support +- _(v3)_ Add with_matching_rules +- Determine version from vcs +- _(v3)_ Upgrade ffi to 0.4.18 +- _(v3)_ Add specification attribute to pacts +- Add support for musllinux_aarch64 + +### 🐛 Bug Fixes + +- _(ci)_ Add missing environment +- _(test)_ Ignore internal deprecation warnings +- _(v3)_ Unconventional __repr__ implementation +- _(v3)_ Add __next__ implementation +- _(example)_ Unknown action +- _(example)_ Publish_verification_results typo +- _(example)_ Publish message pact +- _(v3)_ Rename `with_binary_file` +- _(v3)_ Incorrect arg order +- Clean pact interactions on exception + +### 🚜 Refactor + +- _(v3)_ Split interactions into modules + +### 🎨 Styling + +- Fix pre-commit lints +- [**breaking**] Refactor constants + > The public functions within the constants module have been removed. If you previously used them, please make use of the constants. For example, instead of `pact.constants.broker_client_exe()` use `pact.constants.BROKER_CLIENT_PATH` instead. + +### 📚 Documentation + +- _(v3)_ Update ffi documentation +- _(readme)_ Fix links to examples +- Add git submodule init +- Fix typos + +### ⚙️ Miscellaneous Tasks + +- Add future deprecation warnings +- _(ci)_ Disable on draft pull requests +- _(ci)_ Separate concurrency groups for builds +- Fix hatch test scripts +- _(test)_ Add pytest options in root +- _(build)_ Update packaging to build ffi +- _(tests)_ Add ruff.toml for tests directory +- _(ci)_ Update build targets +- _(v3)_ Create ffi.py +- _(tests)_ Remove empty file +- _(v3)_ Add str and repr to enums +- _(test)_ Move pytest cli args definition +- Add label sync +- _(test)_ Automatically generated xml coverage +- Enable lints fully +- _(pre-commit)_ Add mypy +- _(ffi)_ Add typing +- _(labels)_ Fix incorrect label alias +- Exclude python 3.12 +- Fix wheel builds +- _(ci)_ Revise pypi publishing +- _(tests)_ Reduce log verbosity +- Fix ruff lints +- _(tests)_ Add compatibility suite as submodule +- _(ruff)_ Disable TD002 +- Allow None content type +- _(tests)_ Implement consumer v1 feature +- _(ci)_ Checkout submodules +- _(ci)_ Fix examples testing +- _(ci)_ Clone submodules in Cirrus +- _(tests)_ Automatic submodule init +- Fix lints +- Update submodule +- _(ci)_ Set hatch to be verbose +- _(ci)_ Add test conclusion step +- _(ci)_ Breaking changes with for artifacts +- _(ci)_ Re-enable pypy builds on Windows +- _(dev)_ Replace black with ruff +- _(dev)_ Add markdownlint pre-commit +- _(ci)_ Fix pypy linux builds +- _(test/v3)_ Move bdd steps into shared module +- _(test/v3)_ Add v2 consumer compatibility suite +- _(tests)_ Add v3 consumer compatibility suite +- Update metadata +- _(tests)_ Move the_pact_file_for_the_test_is_generated to share util +- _(tests)_ Add v4 http consumer compatibility suite +- _(ci)_ Speed up wheels building on prs +- _(ci)_ Add caching +- Migrate from flat to src layout +- _(ci)_ Automate release process +- _(v3)_ Add warning on pact.v3 import +- _(ci)_ Remove check of wheels +- _(ci)_ Speed up build pipeline +- _(ci)_ Another build pipeline fix +- _(ci)_ Typo + +### � Other + +- Add g++ to cirrus linux image + +### Contributors + +- @JP-Ellis +- @YOU54F +- @dryobates +- @filipsnastins +- @neringaalt + +## [2.1.0] _2023-10-03_ + +### 🚀 Features + +- _(example)_ Simplify docker-compose +- Bump pact standalone to 2.0.7 + +### 🐛 Bug Fixes + +- _(github)_ Fix typo in template +- _(ci)_ Pypi publish + +### 🎨 Styling + +- Add pre-commit hooks and editorconfig + +### 📚 Documentation + +- Rewrite contributing.md +- Add issue and pr templates +- Incorporate suggestions from @YOU54F + +### ⚙️ Miscellaneous Tasks + +- Add pact-foundation triage automation +- Update pre-commit config +- [**breaking**] Migrate to pyproject.toml and hatch + > Drop support for Python 3.6 and 3.7 +- _(ci)_ Migrate cicd to hatch +- _(example)_ Migrate consumer example +- _(example)_ Migrate fastapi provider example +- _(example)_ Migrate flask provider example +- _(example)_ Update readme +- _(example)_ Migrate message pact example +- _(ci)_ Split tests examples and lints +- _(example)_ Avoid changing python path +- Address pr comments +- _(gitignore)_ Update from upstream templates +- V2.1.0 + +### Contributors + +- @JP-Ellis +- @mefellows + +## [2.0.1] _2023-07-26_ + +### 🚀 Features + +- Update standalone to 2.0.3 + +### ⚙️ Miscellaneous Tasks + +- Update MANIFEST file to note 2.0.2 standalone +- _(examples)_ Update docker setup for non linux os +- Releasing version 2.0.1 + +### Contributors + +- @YOU54F + +## [2.0.0] _2023-07-10_ + +### 🚀 Features + +- Describe classifiers and python version for pypi package +- _(test)_ Add docker images for Python 3.9-3.11 for testing purposes +- Add matchers for ISO 8601 date format +- Support arm64 osx/linux +- Support x86 and x86_64 windows +- Use pact-ruby-standalone 2.0.0 release + +### 🐛 Bug Fixes + +- Actualize doc on how to make contributions +- Remove dead code +- Fix cors parameter not doing anything + +### 🎨 Styling + +- Add missing newline/linefeed + +### 📚 Documentation + +- Add Python 3.11 to CONTRIBUTING.md +- Fix link for GitHub badge +- Fix instruction to build python 3.11 image +- Paraphrase the instructions for running the tests +- Rephrase the instructions for running the tests +- Reformat releasing documentation + +### 🧪 Testing + +- V2.0.1 (pact-2.0.1) - pact-ruby-standalone + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.7.0 +- Do not add merge commits to the change log +- _(docs)_ Update provider verifier options table +- _(docs)_ Correct table +- _(docs)_ Improve table alignment and abs links +- Update to 2.0.2 pact-ruby-standalone +- Releasing version 2.0.0 + +### � Other + +- Correct links in contributing manual +- Improve commit messages guide +- Add python 3.11 to test matrix +- Use compatible dependency versions for Python 3.6 +- Use a single Dockerfile, providing args for the Python version instead of multiple files +- Test arm64 on cirrus-ci / test win/osx on gh +- Skip 3.6 python arm64 failing in cirrus, passing locally with cirrus run +- _(deps)_ Bump flask from 2.2.2 to 2.2.5 in /examples/message +- _(deps)_ Bump flask from 2.2.2 to 2.2.5 in /examples/flask_provider +- _(deps-dev)_ Bump flask from 2.2.2 to 2.2.5 + +### Contributors + +- @YOU54F +- @sergeyklay +- @Lukas-dev-threads +- @elliottmurray +- @mikegeeves + +## [1.7.0] _2023-02-19_ + +### 🚀 Features + +- Enhance provider states for pact-message (#322) + +### 🐛 Bug Fixes + +- Requirements_dev.txt to reduce vulnerabilities (#317) +- Setup security issue (#318) + +### ⚙️ Miscellaneous Tasks + +- Add workflow to create a jira issue for pactflow team when smartbear-supported label added to github issue +- /s/Pactflow/PactFlow +- Releasing version 1.7.0 + +### Contributors + +- @elliottmurray +- @YOU54F +- @nsfrias +- @bethesque +- @mefellows + +## [1.6.0] _2022-09-11_ + +### 🚀 Features + +- Support publish pact with branch (#300) +- Support verify with branch (#302) + +### 📚 Documentation + +- Update docs to reflect usage for native Python + +### ⚙️ Miscellaneous Tasks + +- _(test)_ Fix consumer message test (#301) +- Releasing version 1.6.0 + +### � Other + +- Correct download logic when installing. Add a helper target to setup a pyenv via make (#297) + +### Contributors + +- @elliottmurray +- @YOU54F +- @B3nnyL +- @mikegeeves +- @jnfang + +## [1.5.2] _2022-03-21_ + +### ⚙️ Miscellaneous Tasks + +- Update PACT_STANDALONE_VERSION to 1.88.83 +- Releasing version 1.5.2 + +### Contributors + +- @elliottmurray +- @YOU54F + +## [1.5.1] _2022-03-10_ + +### 🚀 Features + +- Message_pact -> with_metadata() updated to accept term (#289) + +### 📚 Documentation + +- _(examples-consumer)_ Add pip install requirements to the consumer… (#291) + +### 🧪 Testing + +- _(examples)_ Move shared fixtures to a common folder so they can b… (#280) + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.5.1 + +### Contributors + +- @elliottmurray +- @sunsathish88 +- @mikegeeves + +## [1.5.0] _2022-02-05_ + +### 🚀 Features + +- No include pending (#284) + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.5.0 + +### � Other + +- Python36-support-removed (#283) + +### Contributors + +- @elliottmurray +- @abgora +- @mikegeeves + +## [1.4.6] _2022-01-03_ + +### 🚀 Features + +- _(matcher)_ Allow bytes type in from_term function (#281) + +### 🐛 Bug Fixes + +- _(consumer)_ Ensure a description is provided for all interactions + +### 📚 Documentation + +- Docs/examples (#273) + +### 🧪 Testing + +- _(examples-fastapi)_ Tidy FastAPI example, making consistent with Flask (#274) + +### ⚙️ Miscellaneous Tasks + +- Flake8 config to ignore direnv +- Releasing version 1.4.6 + +### Contributors + +- @elliottmurray +- @joshua-badger +- @mikegeeves + +## [1.4.5] _2021-10-11_ + +### 🐛 Bug Fixes + +- Update standalone to 1.88.77 to fix Let's Encrypt CA issue + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.4.5 + +### Contributors + +- @mefellows + +## [1.4.4] _2021-10-02_ + +### 🐛 Bug Fixes + +- _(ruby)_ Update ruby standalone to support disabling SSL verification via an environment variable + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.4.4 + +### Contributors + +- @mefellows +- @m-aciek + +## [1.4.3] _2021-09-05_ + +### 🚀 Features + +- Added support for message provider using pact broker (#257) + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.4.3 + +### Contributors + +- @elliottmurray +- @pulphix + +## [1.4.2] _2021-08-22_ + +### ⚙️ Miscellaneous Tasks + +- Bundle Ruby standalones into dist artifact. (#256) +- Releasing version 1.4.2 + +### Contributors + +- @elliottmurray +- @taj-p + +## [1.4.1] _2021-08-17_ + +### 🐛 Bug Fixes + +- Make uvicorn versions over 0.14 + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.4.1 + +### Contributors + +- @elliottmurray + +## [1.4.0] _2021-08-07_ + +### 🚀 Features + +- Added support for message provider (#251) + +### 🐛 Bug Fixes + +- Issue originating from snyk with requests and urllib + +### ⚙️ Miscellaneous Tasks + +- _(snyk)_ Update fastapi +- Releasing version 1.4.0 + +### Contributors + +- @elliottmurray +- @pulphix + +## [1.3.9] _2021-05-13_ + +### 🐛 Bug Fixes + +- Change default from empty string to empty list (#235) + +### ⚙️ Miscellaneous Tasks + +- _(ruby)_ Update ruby standalen +- Releasing version 1.3.9 + +### Contributors + +- @elliottmurray +- @tephe + +## [1.3.8] _2021-05-01_ + +### 🐛 Bug Fixes + +- Fix datetime serialization issues in Format + +### 📚 Documentation + +- Example uses date matcher + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.3.8 + +### Contributors + +- @elliottmurray +- @DawoudSheraz + +## [1.3.7] _2021-04-24_ + +### 🐛 Bug Fixes + +- _(broker)_ Token added to verify steps + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.3.7 + +### Contributors + +- @elliottmurray + +## [1.3.6] _2021-04-20_ + +### 🐛 Bug Fixes + +- Docker/py36.Dockerfile to reduce vulnerabilities +- Docker/py38.Dockerfile to reduce vulnerabilities +- Docker/py37.Dockerfile to reduce vulnerabilities +- Publish verification results was wrong (#222) + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.3.6 + +### � Other + +- Revert docker36 back + +### Contributors + +- @elliottmurray +- @snyk-bot + +## [1.3.5] _2021-03-28_ + +### 🐛 Bug Fixes + +- _(publish)_ Fixing the fix. Pact Python api uses only publish_version and ensures it follows that + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.3.5 + +### Contributors + +- @elliottmurray + +## [1.3.4] _2021-03-27_ + +### 🐛 Bug Fixes + +- Verifier should now publish + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.3.4 + +### Contributors + +- @elliottmurray + +## [1.3.3] _2021-03-25_ + +### 🐛 Bug Fixes + +- Pass pact_dir to publish() + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.3.3 + +### Contributors + +- @elliottmurray +- @ + +## [1.3.2] _2021-03-21_ + +### 🐛 Bug Fixes + +- Ensure path is passed to broker and allow running from root rather than test file +- Remove pacts from examples + +### ⚙️ Miscellaneous Tasks + +- Move from nose to pytests as we are now 3.6+ +- Update ci stuff +- More clean up +- Wip on using test containers on examples +- Spiking testcontainers +- Added some docs about how to use the e2e example +- Releasing version 1.3.2 + +### Contributors + +- @elliottmurray + +## [1.3.1] _2021-02-27_ + +### 🐛 Bug Fixes + +- Introduced and renamed specification version + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.3.1 + +### Contributors + +- @elliottmurray + +## [1.3.0] _2021-01-26_ + +### 🚀 Features + +- Initial interface +- Add MessageConsumer +- Single message flow +- Create basic tests for single pact message +- Update MessageConsumer and tests +- Add constants test +- Add pact-message integration +- Add pact-message integration test +- Add more test +- Update message pact tests +- Change dummy handler to a message handler +- Update handler to handle error exceptions +- Move publish function to broker class +- Update message handler to be independent of pact +- Address PR comments + +### 🐛 Bug Fixes + +- Send to cli pact_files with the pact_dir in their path +- Add e2e example test into ci back in +- Remove publish fn for now +- Linting +- Add missing conftest +- Try different way to mock +- Flake8 warning +- Revert changes to quotes +- Improve test coverage +- Few more tests to improve coverage + +### 📚 Documentation + +- Add readme for message consumer +- Update readme + +### 🧪 Testing + +- Create external dummy handler in test +- Update message handler condition based on content +- Remove mock and check generated json file +- Consider publish to broker with no pact_dir argument + +### ⚙️ Miscellaneous Tasks + +- Remove python35 and 34 and add 39 +- Fix bad merge +- Add missing files in src +- Add generate_pact_test +- Remove log_dir, refactor test +- Flake8 revert +- Remove test param for provider +- Flake8, clean up deadcode +- Pydocstyle +- Add missing import +- Releasing version 1.3.0 + +### � Other + +- Pr not triggering workflow + +### Contributors + +- @elliottmurray +- @williaminfante +- @tuan-pham +- @cdambo + +## [1.2.11] _2020-12-29_ + +### 🐛 Bug Fixes + +- Not creating wheel + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.2.11 + +### Contributors + +- @elliottmurray + +## [1.2.10] _2020-12-19_ + +### 📚 Documentation + +- Fix small typo in `with_request` doc string +- _(example)_ Created example and have relative imports kinda working. Provider not working as it cant find one of our urls +- Typo in pact-verifier help string: PUT -> POST for --provider-states-setup-url +- Added badge to README + +### ⚙️ Miscellaneous Tasks + +- _(upgrade)_ Upgrade python version to 3.8 +- Wqshell script to run flask in examples +- Added run test to travis +- Releasing version 1.2.10 + +### � Other + +- _(github actions)_ Added Github Actions configuration for build and test +- Removed Travis CI configuration +- Add publishing actions + +### Contributors + +- @elliottmurray +- @matthewbalvanz-wf +- @noelslice +- @hstoebel + +## [1.2.9] _2020-10-19_ + +### 🚀 Features + +- _(verifier)_ Allow setting consumer_version_selectors on Verifier + +### 🐛 Bug Fixes + +- Fix flaky tests using OrderedDict + +### 🎨 Styling + +- Fix linting issues +- Fix one more linting issue + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.2.9 + +### Contributors + +- @elliottmurray +- @thatguysimon + +## [1.2.8] _2020-10-18_ + +### 🚀 Features + +- _(verifier)_ Support include-wip-pacts-since in CLI + +### 🐛 Bug Fixes + +- Fix command building bug + +### 🚜 Refactor + +- Extract input validation in call_verify out into a dedicated method + +### 🎨 Styling + +- Fix linting + +### 📚 Documentation + +- _(examples)_ Tweak to readme +- _(examples)_ Changed provider example to use atexit + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.2.8 + +### Contributors + +- @elliottmurray +- @thatguysimon + +## [1.2.7] _2020-10-09_ + +### 🐛 Bug Fixes + +- _(verifier)_ Headers not propagated properly + +### 📚 Documentation + +- _(examples)_ Removed manual publish to broker + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.2.7 + +### Contributors + +- @elliottmurray + +## [1.2.6] _2020-09-11_ + +### 🚀 Features + +- _(verifier)_ Allow to use unauthenticated brokers + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.2.6 + +### Contributors + +- @elliottmurray +- @copalco + +## [1.2.5] _2020-09-10_ + +### 🚀 Features + +- _(verifier)_ Add enable_pending argument handling in verify wrapper +- _(verifier)_ Pass enable_pending flag in Verifier's methods +- _(verifier)_ Support --enable-pending flag in CLI + +### 🐛 Bug Fixes + +- _(verifier)_ Remove superfluous option from verify CLI command +- _(verifier)_ Remove superfluous verbose mentions + +### 🚜 Refactor + +- _(verifier)_ Add enable_pending to signature of verify methods + +### 🧪 Testing + +- Bump mock to 3.0.5 + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.2.5 + +### � Other + +- _(pre-commit)_ Add commitizen to pre-commit configuration + +### Contributors + +- @elliottmurray +- @ +- @m-aciek + +## [1.2.4] _2020-08-27_ + +### 🚀 Features + +- _(cli)_ Add consumer-version-selector option + +### 📚 Documentation + +- Update README.md with relevant option documentation +- _(cli)_ Improve cli help grammar + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.2.4 + +### Contributors + +- @elliottmurray +- @alecgerona + +## [1.2.3] _2020-08-26_ + +### 🚀 Features + +- Update standalone to 1.88.3 + +### ⚙️ Miscellaneous Tasks + +- Script now uses gh over hub +- Release script updates version automatically now +- Fix release script +- Releasing version 1.2.3 + +### Contributors + +- @elliottmurray + +## [1.2.2] _2020-08-24_ + +### 🚀 Features + +- Added env vars for broker verify + +### 📚 Documentation + +- Https svg + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.2.2 + +### Contributors + +- @elliottmurray + +## [1.2.1] _2020-08-08_ + +### 🐛 Bug Fixes + +- Custom headers had a typo + +### 📚 Documentation + +- Example code verifier +- Merged 2 examples + +### ⚙️ Miscellaneous Tasks + +- Releasing version 1.2.1 + +### Contributors + +- @elliottmurray + +## [1.2.0] _2020-07-24_ + +### 🚀 Features + +- Create beta verifier class and api +- Fixing up tests and examples and code for provider class + +### 🐛 Bug Fixes + +- Change to head from master + +### 📚 Documentation + +- Update links for rendering page correctly in docs.pact.io +- Update stackoverflow link +- Contributing md updated for commit messages + +### ⚙️ Miscellaneous Tasks + +- Add workflow to trigger pact docs update when markdown files change +- Added semantic yml for git messages +- Releasing version 1.2.0 +- Releasing with fix version v1.2.0 + +### � Other + +- Add check for commit messages +- Tweak to regex +- Temporary fix for testing purposes of messages: +- Remove commit message as it is breaking releases + +### Contributors + +- @elliottmurray +- @bethesque + +## [1.1.0] _2020-06-25_ + +### 🚀 Features + +- Update standalone to 1.86.0 + +### ⚙️ Miscellaneous Tasks + +- Removed some files and moved a few things around + +### Contributors + +- @elliottmurray +- @bethesque +- @hstoebel + +## [0.22.0] _2020-05-11_ + +### 🚀 Features + +- Update standalone to 1.84.0 + +### 📚 Documentation + +- Update RELEASING.md + +### ⚙️ Miscellaneous Tasks + +- Add script to create a PR to update the pact-ruby-standalone version + +### Contributors + +- @pyasi +- @elliottmurray +- @bethesque +- @ + +## [0.20.0] _2020-01-16_ + +### 🚀 Features + +- Support using environment variables to set pact broker configuration +- Update to pact-ruby-standalone-1.79.0 + +### Contributors + +- @bethesque +- @matthewbalvanz-wf +- @elliottmurray +- @mikahjc +- @mefellows +- @dlmiddlecote +- @ejrb + +## [0.18.0] _2018-08-21_ + +### ⚙️ Miscellaneous Tasks + +- _(docs)_ Update contact information + +### Contributors + +- @matthewbalvanz-wf +- @mefellows + +## [0.13.0] _2018-01-20_ + +### 📚 Documentation + +- Remove reference to v3 pact in provider-states-setup-url + +### Contributors + +- @matthewbalvanz-wf +- @bethesque +- @ + +## [0.1.0] _2017-04-07_ + +### Contributors + +- @jslvtr +- @matthewbalvanz-wf +- @mefellows + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78d6b3c2a..8fdddca94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,86 +1,194 @@ -# Raising issues +# Contributing to Pact Python -_Before raising an issue, please ensure that you are using the latest version of pact-python._ +Pact Python is the Python implementation of the [Pact](https://pact.io) integration testing framework. If you're interested in contributing to Pact Python, hopefully, this document makes the process for contributing clear. -Please provide the following information with your issue to enable us to respond as quickly as possible. +The [Open Source Guides](https://opensource.guide/) website has a collection of resources for individuals, communities, and companies who want to learn how to run and contribute to an open source project. Contributors and people new to open source alike will find the following guides especially useful: -- The relevant versions of the packages you are using. -- The steps to recreate your issue. -- The full stacktrace if there is an exception. -- An executable code example where possible. You can fork this repository and - use the [examples] directory to quickly recreate your issue. +- [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) +- [Building Welcoming Communities](https://opensource.guide/building-community/) +- [Contributing to Pact](https://docs.pact.io/contributing) -# Contributing +## Get Involved -1. Fork it -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create new Pull Request +There are many ways to contribute to Pact Python and the broader Pact ecosystem. Here's a few ideas to get started: -If you are intending to implement a fairly large feature we'd appreciate if you open -an issue with GitHub detailing your use case and intended solution to discuss how it -might impact other work that is in flight. +- Look through the [open issues](https://github.com/pact-foundation/pact-python/issues). Provide workarounds, ask for clarification, or suggest labels. Help [triage issues](#triaging-issues-and-pull-requests). +- If you find an issue you would like to fix, [open a pull request](#pull-requests). Issues tagged as [_Good first issue_](https://github.com/pact-foundation/pact-python/labels/Good%20first%20issue) are a good place to get started. +- Read through the [docs](https://docs.pact.io). If you find anything that is confusing or can be improved, you can click "Edit this page" at the bottom of most docs, which takes you to the GitHub interface to make and propose changes. +- Take a look at the [features requested](https://github.com/pact-foundation/pact-python/labels/feature) by others in the community and consider opening a pull request if you see something you want to work on. -We also appreciate it if you take the time to update and write tests for any changes -you submit. +Contributions are very welcome. If you think you need help planning your contribution, please ping us on [Slack](https://slack.pact.io) and let us know you are looking for a bit of help. -[examples]: https://github.com/pact-foundation/pact-python/tree/master/examples +### Join our Community -## Commit messages +We have a [Slack](https://slack.pact.io) to discuss all things about Pact and its development. Feel free to ask questions about Pact Python specifically in the [`#pact-python`](https://pact-foundation.slack.com/archives/C9VECUP6E) channel, or broader questions about the Pact ecosystem over in the [`#general`](https://pact-foundation.slack.com/archives/C5F4KFKR8) channel. We store a searchable archive of our Slack channels on [linen.dev](https://linen.dev/s/pact-foundation). -pact-python is adopting the [Conventional Commits](https://www.conventionalcommits.org) -convention. Please ensure you follow the guidelines, we don't want to be that person, -but the commit messages are very important to the automation of our release process. +Questions have also been asked over on StackOverflow, under the [`pact`](https://stackoverflow.com/questions/tagged/pact) tag. This is a great place to ask more general usage questions for pact, and discover existing answers. -Take a look at the git history (`git log`) to get the gist of it. +### Triaging Issues and Pull Requests -If you'd like to get some CLI assistance there is a node npm package. Example usage is: +One great way you can contribute to the project without writing any code is to help triage issues and pull requests as they come in. -```shell -npm install -g commitizen -npm install -g cz-conventional-changelog -echo '{ "path": "cz-conventional-changelog" }' > ~/.czrc -``` +- Ask for more information if you believe the issue does not provide all the details required to solve it. +- Suggest [labels](https://github.com/pact-foundation/pact-python/labels) that can help categorize issues. +- Flag issues that are stale or that should be closed. +- Ask for test plans and review code. -When you commit with Commitizen, you'll be prompted to fill out any required -commit fields at commit time. Simply use `git cz` or just `cz` instead of -`git commit` when committing. You can also use `git-cz`, which is an alias -for `cz`. +## Our Development Process -See https://www.npmjs.com/package/commitizen for more info. +Pact Python uses [GitHub](https://github.com/pact-foundation/pact-python) as its source of truth. The team will be working directly there. All changes will be public from the beginning. -There is a pypi package that does similar [commitizen](https://pypi.org/project/commitizen/). +All pull requests will be checked by the continuous integration system, GitHub actions. There are unit tests, end-to-end tests, performance tests, style tests, and much more. -## Running the tests +### Branch Organization -You can run the tests locally with `make test`, this will run the tests with `tox` +Pact Python has one primary branch `main` and we use feature branches to deliver new features with pull requests. Typically, we scope the branch according to the [conventional commit](#conventional-commit-messages) categories. The more common ones are: -You will need `pyenv` to test on different versions `3.6`, `3.7`, `3.8`, `3.9`, -`3.10`, `3.11`. +- `feature/` or `feat/` for new features +- `fix/` for bug fixes +- `chore/` for chores +- `docs/` for documentation changes -Download and install python versions: -``` -pyenv install 3.6.15 3.7.16 3.8.16 3.9.16 3.10.10 3.11.2 -``` +## Issues -Set these versions locally for the project: -``` -pyenv local 3.6.15 3.7.16 3.8.16 3.9.16 3.10.10 3.11.2 -``` +When [opening a new issue](https://github.com/pact-foundation/pact-python/issues/new/choose), always make sure to fill out the issue template. **This step is very important!** Not doing so may slow down the response. Don't take this personally if this happens, and feel free to open a new issue once you've gathered all the information required by the template. + +**Please don't use the GitHub issue tracker for questions.** If you have questions about using Pact Python, prefer the [Discussion pages](https://github.com/pact-foundation/pact-python/discussions) or [Slack](https://slack.pact.io), and we will do our best to answer your questions. + +### Bugs + +We use [GitHub Issues](https://github.com/pact-foundation/pact-python/issues) for our public bugs. If you would like to report a problem, take a look around and see if someone already opened an issue about it. If you are certain this is a new, unreported bug, you can submit a [bug report](https://github.com/pact-foundation/pact-python/issues/new?assignees=&labels=bug%2Cstatus%3A+needs+triage&template=bug.yml). + +- **One issue, one bug:** Please report a single bug per issue. +- **Provide reproduction steps:** List all the steps necessary to reproduce the issue. The person reading your bug report should be able to follow these steps to reproduce your issue with minimal effort. + +If you're only fixing a bug, it's fine to submit a pull request right away but we still recommend filing an issue detailing what you're fixing. This is helpful in case we don't accept that specific fix but want to keep track of the issue. + +### Feature requests + +If you would like to request a new feature or enhancement but are not yet thinking about opening a pull request, you can file an issue with the [feature template](https://github.com/pact-foundation/pact-python/issues/new?assignees=&labels=feature%2Cstatus%3A+needs+triage&template=feature.yml) for more thought out ideas. + +### Claiming issues + +We have a list of [beginner-friendly issues](https://github.com/pact-foundation/pact-python/labels/good%20first%20issue) to help you get your feet wet in the Pact Python codebase and familiar with our contribution process. This is a great place to get started. + +Apart from the `good first issue`, it is also worth looking at the [`help wanted`](https://github.com/pact-foundation/pact-python/labels/help%20wanted) issues. If you have specific knowledge in one domain, working on these issues can make your expertise shine. + +If you want to work on any of these issues, just drop a message saying "I'd like to work on this", and we will assign the issue to you and update the issue's status as "claimed". + +Alternatively, when opening an issue, you can also click the "self service" checkbox to indicate that you'd like to work on the issue yourself, which will also make us see the issue as "claimed". + +Once an issue is claimed, we hope to see a pull request; however we understand that life happens and you may not be able to complete the issue. If you are unable to complete the issue, please let us know so we can unassign the issue and make it available for others to work on. + +The claiming process is there to help ensure effort is wasted. Even if you are not sure whether you can complete the issue, claiming it will help us know that someone is working on it. If you are not sure how to proceed, feel free to ask for help. + +## Development + +### Online one-click setup for contributing + +You can also try using the new [github.dev](https://github.dev/pact-foundation/pact-python) feature. While you are browsing any file, changing the domain name from `github.com` to `github.dev` will turn your browser into an online editor. You can start making changes and send pull requests right away. This is a great way to get started quickly, but it does not offer the full development environment and you won't be able to run tests. + +### Installation + +1. Ensure you have [Python](https://www.python.org/) installed. + +2. Ensure you have [Hatch](https://hatch.pypa.io/) installed. This is used to manage the development environment can be installed as follows: + + ```sh + python -m pip install --user pipx # If you don't have pipx + pipx install hatch + ``` + +3. After cloning the repository, run `hatch shell` in the root of the repository. This will install all dependencies in a Python virtual environment and then ensure that the virtual environment is activated. You will also need to run `git submodule init` if you want to run tests, as Pact Python makes use of the Pact Compatibility Suite. + +4. To run tests, run `hatch run test` to make sure the test suite is working. You should also make sure the example works by running `hatch run example`. For the examples, you will have to make sure that you have Docker (or a suitable alternative) installed and running. + +5. If you want to run the tests against all supported Python versions, you can run `hatch run test:all`. -Run the tests: +### Code Conventions + +- **Most important: Look around.** Match the style you see used in the rest of the project. This includes formatting, naming files, naming things in code, naming things in documentation, etc. +- "Attractive" +- We do have Ruff to catch most stylistic problems (both linting and formatting). If you are working locally, they should automatically fix some issues during git commits and push. + +Don't worry too much about styles in general—the maintainers will help you fix them as they review your code. + +To help catch a lot of simple formatting or linting issues, you can run `hatch run lint` to run the linter and `hatch run format` to format your code. This process can also be automated by installing [`prek`](https://prek.j178.dev/) to manage pre-commit hooks: + +```sh +prek install ``` -make test + +## Pull Requests + +So you are considering contributing to Pact Python's code? Great! We'd love to have you. First off, please make sure it is related to an existing issue. If not, please open a new issue to discuss the problem you are trying to solve before investing a lot of time into a pull request. While we do accept PRs that are not related to an issue (especially if the PR is very simple), it is best to discuss it first to avoid wasting your time. + +Once you have opened a PR, we will do our best to work with you and get the PR looked at. + +Working on your first Pull Request? You can learn how from this free video series: + +[**How to Contribute to an Open Source Project on GitHub**](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github) + +Please make sure the following is done when submitting a pull request: + +1. **Keep your PR small.** Small pull requests (~300 lines of diff) are much easier to review and more likely to get merged. Make sure the PR does only one thing, otherwise please split it. +2. **Use descriptive titles.** It is recommended to follow this [commit message style](#conventional-commit-messages). +3. **Test your changes.** Describe your [**test plan**](#test-plan) in your pull request description. + +All pull requests should be opened against the `main` branch. + +We have a lot of integration systems that run automated tests to guard against mistakes. The maintainers will also review your code and may fix obvious issues for you. These systems' duty is to make you worry as little about the chores as possible. Your code contributions are more important than sticking to any procedures, although completing the checklist will surely save everyone's time. + +### Conventional Commit Messages + +Pact Python has adopted the [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) convention and we use it to generate our changelog and in the automation of our release process. + +The format is: + +```text +(): ``` -### macOS Setup Guide +`` is optional. If your change is specific to one/two packages, consider adding the scope. Scopes should be brief but recognizable, e.g. `docs`, `ci`, etc. You can take a quick look at the Git history (`git log`) to get the gist. + +The various types of commits: + +- `feat`: a new API or behavior **for the end user**. +- `fix`: a bug fix **for the end user**. +- `docs`: a change to the website or other Markdown documents in our repo. +- `style`: a change to production code that leads to no behavior difference, e.g. splitting files, renaming internal variables, improving code style... +- `test`: adding missing tests, refactoring tests; no production code change. +- `chore`: upgrading dependencies, releasing new versions... Chores that are **regularly done** for maintenance purposes. +- `misc`: anything else that doesn't change production code and doesn't fit in the above. + +If you'd like to get some CLI assistance, you can install [commitizen](https://www.npmjs.com/package/commitizen). The `cz` command line tool will help you write conventional commit messages. + +### Test Plan + +A good test plan has the exact commands you ran and their output. -See the following guides to setup Python and configure `pyenv` on your Mac. +Tests are integrated into our continuous integration system, so you don't always need to run local tests. However, for significant code changes, it saves both your and the maintainers' time if you can do exhaustive tests locally first to make sure your PR is in good shape. There are many types of tests: -- [How to Install Python on macOS](https://realpython.com/installing-python/#how-to-install-python-on-macos) -- [Managing Multiple Python Versions With pyenv](https://realpython.com/intro-to-pyenv/) +- **Build and typecheck.** We use [mypy](https://mypy.readthedocs.io/en/stable/) in our codebase, which can make sure your code is consistent and catches some obvious mistakes early. +- **Unit tests.** We use [pytest](https://pytest.org) for unit tests. You can run `hatch run test` in the root directory to run all tests, and `hatch run test:all` to test against all supported Python versions. + +### Licensing + +By contributing to Pact Python, you agree that your contributions will be licensed under its MIT license. + +### Breaking Changes + +When adding a new breaking change, follow this template in your pull request: + +```md +### New breaking change here + +- **Who does this affect**: +- **How to migrate**: +- **Why make this breaking change**: +- **Severity (number of people affected x effort)**: +``` -## Running the examples +### What Happens Next? -Make sure you have docker running! +The team will be monitoring pull requests. Do help us by keeping pull requests consistent by following the guidelines above. diff --git a/MANIFEST b/MANIFEST deleted file mode 100644 index 610dd70df..000000000 --- a/MANIFEST +++ /dev/null @@ -1,29 +0,0 @@ -# file GENERATED by distutils, do NOT edit -CHANGELOG.md -CONTRIBUTING.md -LICENSE -README.md -RELEASING.md -requirements_dev.txt -setup.cfg -setup.py -pact/__init__.py -pact/__version__.py -pact/broker.py -pact/constants.py -pact/consumer.py -pact/http_proxy.py -pact/matchers.py -pact/message_consumer.py -pact/message_pact.py -pact/message_provider.py -pact/pact.py -pact/provider.py -pact/verifier.py -pact/verify_wrapper.py -pact/bin/pact-1.88.83-linux-x86.tar.gz -pact/bin/pact-1.88.83-linux-x86_64.tar.gz -pact/bin/pact-1.88.83-osx.tar.gz -pact/bin/pact-1.88.83-win32.zip -pact/cli/__init__.py -pact/cli/verify.py diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 160e1d2d4..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,6 +0,0 @@ -include LICENSE -include *.txt -include *.md -include pact/bin/* -prune pact/test -prune e2e diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 000000000..f28020154 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,430 @@ +# Migration Guide + +> [!INFO] +> +> This document is best viewed on the [Pact Python docs site](https://pact-foundation.github.io/pact-python/MIGRATION/). + +This document outlines the key changes and migration steps for users transitioning between major Pact Python versions. It focuses on breaking changes, new features, and best practices to ensure a smooth upgrade. + +## Migrating from `2.x` to `3.x` + +### Key Changes + +- Replaced the entire Pact CLI-based implementation with a brand new version leveraging the [core Pact FFI](https://github.com/pact-foundation/pact-reference) written in Rust. + - This will help ensure feature parity between different language implementations and improve performance and reliability. This also brings compatibility with the latest Pact Specification (v4). +- The bundled CLI is now a separate package: [`pact-python-cli`](https://pypi.org/project/pact-python-cli/). +- The programmatic API has been completely overhauled to be more Pythonic and easier to use. Legacy process calls and return code checks have been removed in favour of proper exception handling. +- The old programmatic interface is available in the `pact.v2` module for backwards compatibility. This is deprecated, will not receive new features, and will be removed in a future release. + +### Using the `pact.v2` Compatibility Module + +For teams with larger codebases that need time to fully migrate to the new v3 API, a backwards compatibility module is provided at `pact.v2`. This module contains the same API as Pact Python v2.x and serves as an interim measure to assist gradual migration. + +To use the v2 compatibility module, you must install pact-python with the `compat-v2` feature enabled: + +```bash +pip install pact-python[compat-v2] +``` + +All existing `pact.*` imports need to be updated to use `pact.v2.*` instead. Here are some common examples: + +```python +# Old v2.x imports +from pact import Consumer, Provider +from pact.matchers import Like, EachLike +from pact.verifier import Verifier + +# New v3.x imports using the v2 compatibility module +from pact.v2 import Consumer, Provider +from pact.v2.matchers import Like, EachLike +from pact.v2.verifier import Verifier +``` + +Please note that this compatibility module is intended as a temporary solution. A full migration to the new v3 API is strongly recommended as soon as feasible. The compatibility module will only receive critical bug fixes and no new features. + +Pact, by default, updates existing Pact contracts in place, so migrating consumer tests incrementally should be feasible. However, newer features (e.g., message pacts) will likely require a full migration, and mixed usage of v2 and v3 APIs is not officially supported. + +### Consumer Changes + +The v3 API introduces significant changes to how consumer tests are structured and written. The main changes simplify the API, making it more Pythonic and flexible. + +#### Defining a Pact + +The `Consumer` and `Provider` classes have been removed. Instead, a single `Pact` class is used to define the consumer-provider relationship: + +```python title="v2" +from pact.v2 import Consumer, Provider + +consumer = Consumer('my-web-front-end') +provider = Provider('my-backend-service') + +pact = consumer.has_pact_with(provider, pact_dir='/path/to/pacts') +``` + +```python title="v3" +from pact import Pact + +pact = Pact('my-web-front-end', 'my-backend-service') +``` + +#### Defining Interactions + +The v3 interface favours method chaining and provides more granular control over request and response definitions. + +```python title="v2" +( + pact + .given('user exists') + .upon_receiving('a request for user data') + .with_request( + 'GET', + '/users/123', + headers={'Accept': 'application/json'}, + query={'include': 'profile'} + ) + .will_respond_with( + 200, + headers={'Content-Type': 'application/json'}, + body={'id': 123, 'name': 'Alice'} + ) +) +``` + +```python title="v3" +( + pact + .upon_receiving('a request for user data') + .given('user exists', id=123, name='Alice') # (1) + .with_request('GET', '/users/123') + .with_header('Accept', 'application/json') + .with_query_parameter('include', 'profile') + .will_respond_with(200) + .with_header('Content-Type', 'application/json') + .with_body({'id': 123, 'name': 'Alice'}, content_type='application/json')) +``` + +1. In v2, there was limited support for parameterizing provider states, and each state variation often required a separate definition. For example, `given("user Alice exists with id 123")` and `given("user Bob exists with id 456")` would be two distinct states, which would then need to be handled separately in the provider state setup. + + The new interface can now define a common descriptor that can be reused with different parameters: `.given("user exists", id=123, name='Alice')` and `.given("user exists", id=456, name='Bob')`. This approach reduces redundancy and makes it easier to manage provider states. + +Some methods are shared across request and response definitions, such as `with_header()` and `with_body()`. Pact Python automatically applies them to the correct part of the interaction based on whether they are called before or after `will_respond_with()`. Alternatively, these methods accept an optional `part` argument to explicitly specify whether they apply to the request or response. + +#### Running Tests + +Pact Python v2 had two different ways to run consumer tests, both of which spawned a separate mock service process. The new v3 API provides a single, consistent way to run tests using the `serve()` method. + +```python title="v2 - with context manager" +pact = Consumer("my-consumer").has_pact_with( + Provider("my-provider"), + host_name="localhost", + port=1234, +) + +# Context manager automatically calls setup() and verify() +with pact: + response = requests.get(pact.uri + '/users/123') +# Pact file written automatically on exit +``` + +```python title="v2 - with manual service management" +pact = Consumer("my-consumer").has_pact_with( + Provider("my-provider"), + host_name="localhost", + port=1234, +) + +# Manually start the mock service +pact.start_service() +pact.setup() # Configure interactions + +# Make requests +response = requests.get(pact.uri + '/users/123') +# Assertions... + +# Verify and stop +pact.verify() # Writes pact file +pact.stop_service() +``` + +The new API entirely replaces both of these approaches with a single, consistent method: + +```python title="v3" +pact = Pact("my-consumer", "my-provider") + +with pact.serve() as srv: + response = requests.get(f"{srv.url}/users/123") +``` + +The server host and port can be specified in `serve()` if needed, but by default, the server binds to `localhost` on a random available port. More details can be found in the [API reference][pact.pact.Pact.serve]. + +##### Writing Pact Files + +Since the old v2 API executed a sub-process for the mock service, the Pact file was automatically written when the context manager exited or when `pact.verify()` was called. The new v3 API runs the mock service in-process, so the Pact file must be written explicitly using the `write_file()` method: + +```python title="v2" +with pact: + # tests... +# Pact file written automatically +``` + +```python title="v3" +pact = Pact('consumer', 'provider') +# Define interactions and run tests... +pact.write_file('/path/to/pacts') +``` + +#### Matchers + +Support for matchers has been greatly expanded and improved in the v3 API. The older v2 classes defined a limited set of matchers, while the new API provides a more comprehensive and flexible approach. + +```python title="v2" +from pact.v2.matchers import Like, EachLike, Regex, Term + +# Usage: +Like({'id': 123}) +EachLike({'item': 'value'}) +Regex('hello world', r'^hello') +``` + +```python title="v3" +from pact import match + +# Usage: +match.like({'id': 123}) +match.each_like({'item': 'value'}) +match.regex('hello world', r'^hello') +``` + +For a full list of available matchers and their usage, refer to the [API documentation][pact.match]. + +### Provider Changes + +The provider verification API has been completely redesigned in v3 to provide a more intuitive and flexible interface. The old `Provider` and `Verifier` classes have been replaced by a single `Verifier` class with a fluent interface. + +#### Creating a Verifier + +```python title="v2" +from pact.v2 import Provider, Verifier + +# Create separate Provider and Verifier instances +provider = Provider('my-provider') +verifier = Verifier(provider, 'http://localhost:8080') +``` + +```python title="v3" +from pact import Verifier + +# Single Verifier instance with provider name +verifier = Verifier('my-provider') +``` + +The protocol specification is now done through the `add_transport` method, which allows for more flexible configuration and supports multiple transports if needed. + +```python title="v2" +verifier = Verifier(provider, 'http://localhost:8080') +``` + +```python title="v3" +verifier = ( + Verifier('my-provider') + .add_transport(url='http://localhost:8080') + # Or more granular control: + .add_transport( + protocol='http', + port=8080, + path='/api/v1', + scheme='https' + ) +) +``` + +#### Adding Pact Sources + +Support for both local files and Pact Brokers is retained in v3, with the `verify_pacts` and `verify_with_broker` methods replaced by a more flexible source configuration. This allows multiple sources to be combined, and selectors to be applied. + +/// tab | Local Files + +```python title="v2" +success, logs = verifier.verify_pacts( + './pacts/consumer1-provider.json', + './pacts/consumer2-provider.json' +) +``` + +```python title="v3" +verifier = ( + Verifier('my-provider') + # It can discover all Pact files in a directory + .add_source('./pacts/') + # Or read individual files + .add_source('./pacts/specific-consumer.json') +) +``` + +/// + +/// tab | Pact Broker + +```python title="v2" +success, logs = verifier.verify_with_broker( + broker_url='https://pact-broker.example.com', + broker_username='username', + broker_password='password' +) +``` + +```python title="v3" +verifier = ( + Verifier('my-provider') + .broker_source( + 'https://pact-broker.example.com', + username='username', + password='password' + ) +) + +# Or with selectors for more control +broker_builder = ( + verifier + .broker_source( + 'https://pact-broker.example.com', + selector=True + ) + .include_pending() + .provider_branch('main') + .consumer_version(branch='main') + .consumer_version(branch='develop') + .build() +) +``` + +The `selector=True` argument returns a [`BrokerSelectorBuilder`][pact.verifier.BrokerSelectorBuilder] instance, which provides methods to configure which pacts to fetch. The `build()` call finalizes the configuration and returns the `Verifier` instance which can then be further configured. + +The `consumer_version` method provides fine-grained control over which consumer pacts are verified and can be called multiple times to add multiple selectors (combined with a logical OR). The older `consumer_versions` method is now deprecated in favor of `consumer_version`. + +/// + +#### Provider State Handling + +The old v2 API required the provider to expose an HTTP endpoint dedicated to handling provider states. This is still supported in v3, but there are now more flexible options, allowing Python functions (or mappings of state names to functions) to be used instead. + +/// tab | URL-based State Handling + +```python title="v2" +success, logs = verifier.verify_pacts( + './pacts/consumer-provider.json', + provider_states_setup_url='http://localhost:8080/_pact/provider_states' +) +``` + +```python title="v3" +# Option 1: URL-based (similar to v2) +verifier = ( + Verifier('my-provider') + .add_transport(url='http://localhost:8080') + .state_handler( + 'http://localhost:8080/_pact/provider_states', + body=True # (1) + ) + .add_source('./pacts/') +) +``` + +1. The `body` argument specifies whether to use a `POST` request and pass information in the body, or to use a `GET` request and pass information through HTTP headers. For more details, see the [`state_handler` API documentation][pact.verifier.Verifier.state_handler]. + +/// + +//// tab | Functional State Handling + +```python title="v2" +# Not supported +``` + +```python title="v3 - Function" +def handler(name, params=None): + if name == 'user exists': + # Set up user in database/mock + create_user(params.get('id', 123)) + elif name == 'no users exist': + # Clear users + clear_users() + + verifier = ( + Verifier('my-provider') + .add_transport(url='http://localhost:8080') + .state_handler(handler) + .add_source('./pacts/') + ) +``` + +```python title="v3 - Mapping" +state_handlers = { + 'user exists': lambda name, params: create_user(params.get('id', 123)), + 'no users exist': lambda name, params: clear_users(), +} + +verifier = ( + Verifier('my-provider') + .add_transport(url='http://localhost:8080') + .state_handler(state_handlers) + .add_source('./pacts/') +) +``` + +More information on the state handler function signature can be found in the [`state_handler` API documentation][pact.verifier.Verifier.state_handler]. By default, the handlers only _set up_ the provider state. If you need to also _tear down_ the state after verification, you can use the `teardown=True` argument to enable this behaviour. + +/// warning +These functions run in the test process, so any side effects must be properly shared with the provider. If using mocking libraries, ensure the provider is started in a separate thread of the same process (using `threading.Thread` or similar), rather than a separate process (e.g., using `multiprocessing.Process` or `subprocess.Popen`). +/// + +//// + +#### Message Verification + +Message verification is now much more straightforward in v3, with a a similar interface to HTTP verification and fixes a number of issues and deficiencies present in the v2 implementation (including the swapped behaviour of `expects_to_receive` and `given`, and the lack of support for matchers and generators). + +```python title="v3 - Functional Handler" +def message_handler(description, metadata): + if description == 'user created event': + return { + 'id': 123, + 'name': 'Alice', + 'event': 'created' + } + elif description == 'user deleted event': + return {'id': 123, 'event': 'deleted'} + +verifier = ( + Verifier('my-provider') + .message_handler(message_handler) + .add_source('./pacts/') +) +``` + +```python title="v3 - Dictionary Mapping" +messages = { + 'user created event': {'id': 123, 'name': 'Alice', 'event': 'created'}, + 'user deleted event': lambda desc, meta: {'id': 123, 'event': 'deleted'} +} + +verifier = ( + Verifier('my-provider') + .message_handler(messages) + .add_source('./pacts/') +) +``` + +#### Running Verification + +Verification has been simplified and no longer requires checking return codes. Instead, the `verify()` method raises an exception on failure, or returns normally on success. + +```python title="v2" +success, logs = verifier.verify_pacts('./pacts/consumer-provider.json') +if not success: + print(logs) + raise AssertionError("Verification failed!") +``` + +```python title="v3" +verifier.verify() +``` diff --git a/Makefile b/Makefile index c12e80dc6..de2363d3a 100644 --- a/Makefile +++ b/Makefile @@ -1,138 +1,57 @@ -DOCS_DIR := ./docs - -PROJECT := pact-python -PYTHON_MAJOR_VERSION := 3.9 - -sgr0 := $(shell tput sgr0) -red := $(shell tput setaf 1) -green := $(shell tput setaf 2) - help: @echo "" @echo " clean to clear build and distribution directories" - @echo " deps to install the required files for development" - @echo " examples to run the example end to end tests (consumer, fastapi, flask, messaging)" - @echo " consumer to run the example consumer tests" - @echo " fastapi to run the example FastApi provider tests" - @echo " flask to run the example Flask provider tests" - @echo " messaging to run the example messaging e2e tests" - @echo " package to create a distribution package in /dist/" + @echo " package to build a wheel and sdist" @echo " release to perform a release build, including deps, test, and package targets" - @echo " test to run all tests" - @echo " venv to setup a venv under .venv using pyenv, if available" @echo "" + @echo " test to run all tests on the current python version" + @echo " test-all to run all tests on all supported python versions" + @echo " example to run the example end to end tests (requires docker)" + @echo " lint to run the lints" + @echo " ci to run test and lints" + @echo "" + @echo " help to show this help message" + @echo "" + @echo "Most of these targets are just wrappers around hatch commands." + @echo "See https://hatch.pypa.org for information to install hatch." .PHONY: release -release: deps test package +release: clean test package .PHONY: clean clean: - rm -rf build - rm -rf dist - rm -rf pact/bin - - -.PHONY: deps -deps: - pip install -r requirements_dev.txt -e . - - -define CONSUMER - echo "consumer make" - cd examples/consumer - pip install -q -r requirements.txt - pip install -e ../../ - ./run_pytest.sh -endef -export CONSUMER - - -define FLASK_PROVIDER - echo "flask make" - cd examples/flask_provider - pip install -q -r requirements.txt - pip install -e ../../ - ./run_pytest.sh -endef -export FLASK_PROVIDER - - -define FASTAPI_PROVIDER - echo "fastapi make" - cd examples/fastapi_provider - pip install -q -r requirements.txt - pip install -e ../../ - ./run_pytest.sh -endef -export FASTAPI_PROVIDER - - -define MESSAGING - echo "messaging make" - cd examples/message - pip install -q -r requirements.txt - pip install -e ../../ - ./run_pytest.sh -endef -export MESSAGING - - -.PHONY: consumer -consumer: - bash -c "$$CONSUMER" - - -.PHONY: flask -flask: - bash -c "$$FLASK_PROVIDER" - - -.PHONY: fastapi -fastapi: - bash -c "$$FASTAPI_PROVIDER" - - -.PHONY: messaging -messaging: - bash -c "$$MESSAGING" - - -.PHONY: examples -examples: consumer flask fastapi messaging + hatch clean .PHONY: package package: - python setup.py sdist + hatch build .PHONY: test -test: deps - flake8 - pydocstyle pact - coverage erase - tox - coverage report -m --fail-under=100 +test: + hatch run test + hatch run coverage report -m --fail-under=100 + + +.PHONY: test-all +test-all: + hatch run test:test -.PHONY: venv -venv: - @if [ -d "./.venv" ]; then echo "$(red).venv already exists, not continuing!$(sgr0)"; exit 1; fi - @type pyenv >/dev/null 2>&1 || (echo "$(red)pyenv not found$(sgr0)"; exit 1) - @echo "\n$(green)Try to find the most recent minor version of the major version specified$(sgr0)" - $(eval PYENV_VERSION=$(shell pyenv install -l | grep "\s\s$(PYTHON_MAJOR_VERSION)\.*" | tail -1 | xargs)) - @echo "$(PYTHON_MAJOR_VERSION) -> $(PYENV_VERSION)" +.PHONY: example +example: + hatch run example - @echo "\n$(green)Install the Python pyenv version if not already available$(sgr0)" - pyenv install $(PYENV_VERSION) -s - @echo "\n$(green)Make a .venv dir$(sgr0)" - ~/.pyenv/versions/${PYENV_VERSION}/bin/python3 -m venv ${CURDIR}/.venv +.PHONY: lint +lint: + hatch run lint + hatch run format + hatch run typecheck - @echo "\n$(green)Make it 'available' to pyenv$(sgr0)" - ln -sf ${CURDIR}/.venv ~/.pyenv/versions/${PROJECT} - @echo "\n$(green)Use it! (populate .python-version)$(sgr0)" - pyenv local ${PROJECT} \ No newline at end of file +.PHONY: ci +ci: test lint diff --git a/README.md b/README.md index 4ea4bfbd0..fafde71d2 100644 --- a/README.md +++ b/README.md @@ -1,571 +1,174 @@ -# pact-python - -[![slack](https://slack.pact.io/badge.svg)](https://slack.pact.io) -[![License](https://img.shields.io/github/license/pact-foundation/pact-python.svg?maxAge=2592000)](https://github.com/pact-foundation/pact-python/blob/master/LICENSE) -[![Build and Test](https://github.com/pact-foundation/pact-python/actions/workflows/build_and_test.yml/badge.svg)](https://github.com/pact-foundation/pact-python/actions/workflows/build_and_test.yml) - -Python version of Pact. Enables consumer driven contract testing, -providing a mock service and DSL for the consumer project, and -interaction playback and verification for the service provider project. -Currently supports version 2 of the [Pact specification]. - -For more information about what Pact is, and how it can help you -test your code more efficiently, check out the [Pact documentation]. - -Note: As of Version 1.0 deprecates support for python 2.7 to allow us to incorporate python 3.x features more readily. If you want to still use Python 2.7 use the 0.x.y versions. Only bug fixes will now be added to that release. - -# How to use pact-python +# Pact Python + + +
+ Pact Python Mascot + + Fast, easy and reliable testing for your APIs and microservices. + +
+ +
+ + + + + + + + + + + + + + + + +
Package + Version + Python Versions + Downloads +
CI/CD + Test Status + Build Status + Build Status +
Meta + Hatch project + linting - Ruff + style - Ruff + types - Mypy + License +
Community + Issues + Discussions + GitHub Stars +
+ Slack + Stack Overflow + Twitter +
+ +
+Pact is the de-facto API contract testing tool. Replace expensive and brittle end-to-end integration tests with fast, reliable and easy to debug unit tests. + +
    +
  • ⚡ Lightning fast
  • +
  • 🎈 Effortless full-stack integration testing - from the front-end to the back-end
  • +
  • 🔌 Supports HTTP/REST and event-driven systems
  • +
  • 🛠️ Configurable mock server
  • +
  • 😌 Powerful matching rules prevents brittle tests
  • +
  • 🤝 Integrates with Pact Broker / PactFlow for powerful CI/CD workflows
  • +
  • 🔡 Supports 12+ languages
  • +
+ +Why use Pact? Contract testing with Pact lets you: + +
    +
  • ⚡ Test locally
  • +
  • 🚀 Deploy faster
  • +
  • ⬇️ Reduce the lead time for change
  • +
  • 💰 Reduce the cost of API integration testing
  • +
  • 💥 Prevent breaking changes
  • +
  • 🔎 Understand your system usage
  • +
  • 📃 Document your APIs for free
  • +
  • 🗄 Remove the need for complex data fixtures
  • +
  • 🤷‍♂️ Reduce the reliance on complex test environments
  • +
+ +Watch our series on the problems with end-to-end integrated tests, and how contract testing can help. + +
+ + + +## Documentation + +This readme provides a high-level overview of the Pact Python library. For detailed documentation, please refer to the [full Pact Python documentation](https://pact-foundation.github.io/pact-python). For a more general overview of Pact and the rest of the ecosystem, please refer to the [Pact documentation](https://docs.pact.io). + +- [Installation](#installation) +- [Consumer testing](docs/consumer.md) +- [Provider testing](docs/provider.md) +- [Examples](examples/README.md) + +Documentation for the API is generated from the docstrings in the code which you can view at [`pact-foundation.github.io/pact-python/pact`](https://pact-foundation.github.io/pact-python/api). + +### Need Help + +- [Join](https://slack.pact.io) our community [slack workspace][Pact Foundation Slack]. +- [Stack Overflow](https://stackoverflow.com/questions/tagged/pact) is a great place to ask questions. +- Say 👋 on Twitter: [@pact_up](https://twitter.com/pact_up) +- Join a discussion 💬 on [GitHub Discussions] +- [Raise an issue][GitHub Issues] on GitHub + +[Pact Foundation Slack]: https://pact-foundation.slack.com/ +[GitHub Discussions]: https://github.com/pact-foundation/pact-python/discussions +[GitHub Issues]: https://github.com/pact-foundation/pact-python/issues ## Installation -``` -pip install pact-python -``` - -## Getting started - -A guide follows but if you go to the [examples](https://github.com/pact-foundation/pact-python/tree/master/examples). This has a consumer, provider and pact-broker set of tests for both FastAPI and Flask. - -## Writing a Pact - -Creating a complete contract is a two step process: - -1. Create a test on the consumer side that declares the expectations it has of the provider -2. Create a provider state that allows the contract to pass when replayed against the provider - -## Writing the Consumer Test - -If we have a method that communicates with one of our external services, which we'll call -`Provider`, and our product, `Consumer` is hitting an endpoint on `Provider` at -`/users/` to get information about a particular user. - -If the code to fetch a user looked like this: - -```python -import requests - - -def user(user_name): - """Fetch a user object by user_name from the server.""" - uri = 'http://localhost:1234/users/' + user_name - return requests.get(uri).json() -``` - -Then `Consumer`'s contract test might look something like this: - -```python -import atexit -import unittest - -from pact import Consumer, Provider - - -pact = Consumer('Consumer').has_pact_with(Provider('Provider')) -pact.start_service() -atexit.register(pact.stop_service) - - -class GetUserInfoContract(unittest.TestCase): - def test_get_user(self): - expected = { - 'username': 'UserA', - 'id': 123, - 'groups': ['Editors'] - } - - (pact - .given('UserA exists and is not an administrator') - .upon_receiving('a request for UserA') - .with_request('get', '/users/UserA') - .will_respond_with(200, body=expected)) - - with pact: - result = user('UserA') - - self.assertEqual(result, expected) - -``` - -This does a few important things: - - - Defines the Consumer and Provider objects that describe our product and our service under test - - Uses `given` to define the setup criteria for the Provider `UserA exists and is not an administrator` - - Defines what the request that is expected to be made by the consumer will contain - - Defines how the server is expected to respond - -Using the Pact object as a [context manager], we call our method under test -which will then communicate with the Pact mock service. The mock service will respond with -the items we defined, allowing us to assert that the method processed the response and -returned the expected value. If you want more control over when the mock service is -configured and the interactions verified, use the `setup` and `verify` methods, respectively: - -```python - (pact - .given('UserA exists and is not an administrator') - .upon_receiving('a request for UserA') - .with_request('get', '/users/UserA') - .will_respond_with(200, body=expected)) - - pact.setup() - # Some additional steps before running the code under test - result = user('UserA') - # Some additional steps before verifying all interactions have occurred - pact.verify() -``` - -### Requests - -When defining the expected HTTP request that your code is expected to make you -can specify the method, path, body, headers, and query: - -```python -pact.with_request( - method='GET', - path='/api/v1/my-resources/', - query={'search': 'example'} -) -``` - -`query` is used to specify URL query parameters, so the above example expects -a request made to `/api/v1/my-resources/?search=example`. - -```python -pact.with_request( - method='POST', - path='/api/v1/my-resources/123', - body={'user_ids': [1, 2, 3]}, - headers={'Content-Type': 'application/json'}, -) -``` - -You can define exact values for your expected request like the examples above, -or you can use the matchers defined later to assist in handling values that are -variable. - -The default hostname and port for the Pact mock service will be -`localhost:1234` but you can adjust this during Pact creation: - -```python -from pact import Consumer, Provider -pact = Consumer('Consumer').has_pact_with( - Provider('Provider'), host_name='mockservice', port=8080) -``` - -This can be useful if you need to run to create more than one Pact for your test -because your code interacts with two different services. It is important to note -that the code you are testing with this contract _must_ contact the mock service. -So in this example, the `user` method could accept an argument to specify the -location of the server, or retrieve it from an environment variable so you can -change its URI during the test. - -The mock service offers you several important features when building your contracts: -- It provides a real HTTP server that your code can contact during the test and provides the responses you defined. -- You provide it with the expectations for the request your code will make and it will assert the contents of the actual requests made based on your expectations. -- If a request is made that does not match one you defined or if a request from your code is missing it will return an error with details. -- Finally, it will record your contracts as a JSON file that you can store in your repository or publish to a Pact broker. - -## Expecting Variable Content -The above test works great if that user information is always static, but what happens if -the user has a last updated field that is set to the current time every time the object is -modified? To handle variable data and make your tests more robust, there are 3 helpful matchers: - -### Term(matcher, generate) -Asserts the value should match the given regular expression. You could use this -to expect a timestamp with a particular format in the request or response where -you know you need a particular format, but are unconcerned about the exact date: - -```python -from pact import Term -... -body = { - 'username': 'UserA', - 'last_modified': Term('\d+-\d+-\d+T\d+:\d+:\d+', '2016-12-15T20:16:01') -} - -(pact - .given('UserA exists and is not an administrator') - .upon_receiving('a request for UserA') - .with_request('get', '/users/UserA/info') - .will_respond_with(200, body=body)) -``` - -When you run the tests for the consumer, the mock service will return the value you provided -as `generate`, in this case `2016-12-15T20:16:01`. When the contract is verified on the -provider, the regex will be used to search the response from the real provider service -and the test will be considered successful if the regex finds a match in the response. - -### Like(matcher) -Asserts the element's type matches the matcher. For example: - -```python -from pact import Like -Like(123) # Matches if the value is an integer -Like('hello world') # Matches if the value is a string -Like(3.14) # Matches if the value is a float -``` -The argument supplied to `Like` will be what the mock service responds with. - -When a dictionary is used as an argument for Like, all the child objects (and their child objects etc.) will be matched according to their types, unless you use a more specific matcher like a Term. - -```python -from pact import Like, Term -Like({ - 'username': Term('[a-zA-Z]+', 'username'), - 'id': 123, # integer - 'confirmed': False, # boolean - 'address': { # dictionary - 'street': '200 Bourke St' # string - } -}) - -``` - -### EachLike(matcher, minimum=1) -Asserts the value is an array type that consists of elements -like the one passed in. It can be used to assert simple arrays: - -```python -from pact import EachLike -EachLike(1) # All items are integers -EachLike('hello') # All items are strings -``` - -Or other matchers can be nested inside to assert more complex objects: - -```python -from pact import EachLike, Term -EachLike({ - 'username': Term('[a-zA-Z]+', 'username'), - 'id': 123, - 'groups': EachLike('administrators') -}) -``` - -> Note, you do not need to specify everything that will be returned from the Provider in a -> JSON response, any extra data that is received will be ignored and the tests will still pass. - -> Note, to get the generated values from an object that can contain matchers like Term, Like, EachLike, etc. -> for assertion in self.assertEqual(result, expected) you may need to use get_generated_values() helper function: - -```python -from pact.matchers import get_generated_values -self.assertEqual(result, get_generated_values(expected)) -``` - -### Match common formats -Often times, you find yourself having to re-write regular expressions for common formats. - -```python -from pact import Format -Format().integer # Matches if the value is an integer -Format().ip_address # Matches if the value is a ip address -``` - -We've created a number of them for you to save you the time: - -| matcher | description | -|-----------------|-------------------------------------------------------------------------------------------------| -| `identifier` | Match an ID (e.g. 42) | -| `integer` | Match all numbers that are integers (both ints and longs) | -| `decimal` | Match all real numbers (floating point and decimal) | -| `hexadecimal` | Match all hexadecimal encoded strings | -| `date` | Match string containing basic ISO8601 dates (e.g. 2016-01-01) | -| `timestamp` | Match a string containing an RFC3339 formatted timestapm (e.g. Mon, 31 Oct 2016 15:21:41 -0400) | -| `time` | Match string containing times in ISO date format (e.g. T22:44:30.652Z) | -| `ip_address` | Match string containing IP4 formatted address | -| `ipv6_address` | Match string containing IP6 formatted address | -| `uuid` | Match strings containing UUIDs | - -These can be used to replace other matchers - -```python -from pact import Like, Format -Like({ - 'id': Format().integer, # integer - 'lastUpdated': Format().timestamp, # timestamp - 'location': { # dictionary - 'host': Format().ip_address # ip address - } -}) -``` - -For more information see [Matching](https://docs.pact.io/getting_started/matching) - -## Uploading pact files to a Pact Broker - -There are two ways to publish your pact files, to a Pact Broker. - -1. [Pact CLI tools](https://docs.pact.io/pact_broker/client_cli) **recommended** -2. Pact Python API - -### CLI - -See [Publishing and retrieving pacts](https://docs.pact.io/pact_broker/publishing_and_retrieving_pacts) - -Example uploading to a Pact Broker -``` -pact-broker publish /path/to/pacts/consumer-provider.json --consumer-app-version 1.0.0 --branch main --broker-base-url https://test.pactflow.io --broker-username someUsername --broker-password somePassword -``` - -Example uploading to a PactFlow Broker - -``` -pact-broker publish /path/to/pacts/consumer-provider.json --consumer-app-version 1.0.0 --branch main --broker-base-url https://test.pactflow.io --broker-token SomeToken -``` - -### Python API - -```python -broker = Broker(broker_base_url="http://localhost") -broker.publish("TestConsumer", - "2.0.1", - branch='consumer-branch', - pact_dir='.') - -output, logs = verifier.verify_pacts('./userserviceclient-userservice.json') - -``` - -The parameters for this differ slightly in naming from their CLI equivalents: -| CLI | native Python | -|-----------------|-------------------------------------------------------------------------------------------------| -| `--branch` | `branch` | -| `--build-url` | `build_url` | -| `--auto-detect-version-properties` | `auto_detect_version_properties` | -| `--tag=TAG` | `consumer_tags` | -| `--tag-with-git-branch` | `tag_with_git_branch` | -| `PACT_DIRS_OR_FILES` | `pact_dir` | -| `--consumer-app-version` | `version` | -| `n/a` | `consumer_name` | - -## Verifying Pacts Against a Service - -In addition to writing Pacts for Python consumers, you can also verify those Pacts -against a provider of any language. There are two ways to do this. - -### CLI - -After installing pact-python a `pact-verifier` -application should be available. To get details about its use you can call it with the -help argument: - -```bash -pact-verifier --help -``` - -The simplest example is verifying a server with locally stored Pact files and no provider -states: - -```bash -pact-verifier --provider-base-url=http://localhost:8080 --pact-url=./pacts/consumer-provider.json -``` - -Which will immediately invoke the Pact verifier, making HTTP requests to the server located -at `http://localhost:8080` based on the Pacts in `./pacts/consumer-provider.json` and -reporting the results. - -There are several options for configuring how the Pacts are verified: - -###### --provider-base-url - -Required. Defines the URL of the server to make requests to when verifying the Pacts. - -###### --pact-url - -Required if --pact-urls not specified. The location of a Pact file you want -to verify. This can be a URL to a [Pact Broker] or a local path, to provide -multiple files, specify multiple arguments. - -``` -pact-verifier --provider-base-url=http://localhost:8080 --pact-url=./pacts/one.json --pact-url=./pacts/two.json -``` - -###### --pact-urls - -Required if --pact-url not specified. The location of the Pact files you want -to verify. This can be a URL to a [Pact Broker] or one or more local paths, separated by a comma. - -###### --provider-states-url - -_DEPRECATED AFTER v 0.6.0._ The URL where your provider application will produce the list of available provider states. -The verifier calls this URL to ensure the Pacts specify valid states before making the HTTP -requests. - -###### --provider-states-setup-url - -The URL which should be called to setup a specific provider state before a Pact is verified. This URL will be called with a POST request, and the JSON body `{consumer: 'Consumer name', state: 'a thing exists'}`. - -###### --pact-broker-url - -Base URl for the Pact Broker instance to publish pacts to. Can also be specified via the environment variable -`PACT_BROKER_BASE_URL`. - -###### --pact-broker-username - -The username to use when contacting the Pact Broker. Can also be specified via the environment variable -`PACT_BROKER_USERNAME`. - -###### --pact-broker-password - -The password to use when contacting the Pact Broker. You can also specify this value -as the environment variable `PACT_BROKER_PASSWORD`. - -###### --pact-broker-token - -The bearer token to use when contacting the Pact Broker. You can also specify this value -as the environment variable `PACT_BROKER_TOKEN`. +The latest version of Pact Python can be installed from PyPi: -###### --consumer-version-tag - -Retrieve the latest pacts with this consumer version tag. Used in conjunction with `--provider`. -May be specified multiple times. - -###### --consumer-version-selector - -You can also retrieve pacts with consumer version selector, a more flexible approach in specifying which pacts you need. -May be specified multiple times. Read more about selectors [here](https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/). - -###### --provider-version-tag - -Tag to apply to the provider application version. May be specified multiple times. - -###### --provider-version-branch - -Branch to apply to the provider application version. - -###### --custom-provider-header - -Header to add to provider state set up and pact verification requests e.g.`Authorization: Basic cGFjdDpwYWN0` -May be specified multiple times. - -###### -t, --timeout - -The duration in seconds we should wait to confirm that the verification process was successful. Defaults to 30. - -###### -a, --provider-app-version - -The provider application version. Required for publishing verification results. - -###### -r, --publish-verification-results - -Publish verification results to the broker. - -### Python API -You can use the Verifier class. This allows you to write native python code and the test framework of your choice. - -```python -verifier = Verifier(provider='UserService', - provider_base_url=PACT_URL) - -# Using a local pact file - -success, logs = verifier.verify_pacts('./userserviceclient-userservice.json') -assert success == 0 - -# Using a pact broker - -- For OSS Pact Broker, use broker_username / broker_password -- For PactFlow Pact Broker, use broker_token - -success, logs = verifier.verify_with_broker( - # broker_username=PACT_BROKER_USERNAME, - # broker_password=PACT_BROKER_PASSWORD, - broker_url=PACT_BROKER_URL, - broker_token=PACT_BROKER_TOKEN, - publish_version=APPLICATION_VERSION, - publish_verification_results=True, - verbose=True, - provider_version_branch=PROVIDER_BRANCH, - enable_pending=True, -) -assert success == 0 - -``` - -The parameters for this differ slightly in naming from their CLI equivalents: -| CLI | native Python | -|-----------------|-------------------------------------------------------------------------------------------------| -| `consumer_tags` | `consumer-version-tag` | -| `provider_tags` | `provider-version-tag` | -| `custom-provider-header` | `headers` | - -You can see more details in the [e2e examples](https://github.com/pact-foundation/pact-python/tree/master/examples/e2e/tests/provider/test_provider.py). - -### Provider States -In many cases, your contracts will need very specific data to exist on the provider -to pass successfully. If you are fetching a user profile, that user needs to exist, -if querying a list of records, one or more records needs to exist. To support -decoupling the testing of the consumer and provider, Pact offers the idea of provider -states to communicate from the consumer what data should exist on the provider. - -When setting up the testing of a provider you will also need to setup the management of -these provider states. The Pact verifier does this by making additional HTTP requests to -the `--provider-states-setup-url` you provide. This URL could be -on the provider application or a separate one. Some strategies for managing state include: - -- Having endpoints in your application that are not active in production that create and delete your datastore state -- A separate application that has access to the same datastore to create and delete, like a separate App Engine module or Docker container pointing to the same datastore -- A standalone application that can start and stop the other server with different datastore states - -For more information about provider states, refer to the [Pact documentation] on [Provider States]. - -# Development - -Please read [CONTRIBUTING.md](https://github.com/pact-foundation/pact-python/blob/master/CONTRIBUTING.md) - -To setup a development environment: - -1. If you want to run tests for all Python versions, install 2.7, 3.3, 3.4, 3.5, and 3.6 from source or using a tool like [pyenv] -2. Its recommended to create a Python [virtualenv] for the project - -To setup the environment, run tests, and package the application, run: -`make release` - -If you are just interested in packaging pact-python so you can install it using pip: - -`make package` - -This creates a `dist/pact-python-N.N.N.tar.gz` file, where the Ns are the current version. -From there you can use pip to install it: - -`pip install ./dist/pact-python-N.N.N.tar.gz` - -## Offline Installation of Standalone Packages - -Although all Ruby standalone applications are predownloaded into the wheel artifact, it may be useful, for development, purposes to install custom Ruby binaries. In which case, use the `bin-path` flag. -``` -pip install pact-python --bin-path=/absolute/path/to/folder/containing/pact/binaries/for/your/os +```console +pip install pact-python +# 🚀 now write some tests! ``` -Pact binaries can be found at [Pact Ruby Releases](https://github.com/pact-foundation/pact-ruby-standalone/releases). - -## Testing - -This project has unit and end to end tests, which can both be run from make: +### Requirements -Unit: `make test` +Pact Python tries to support all versions of Python that are still supported by the Python Software Foundation. Older version of Python may work, but are not officially supported. -End to end: `make e2e` +In order to support the broadest range of use cases, Pact Python tries to impose the least restrictions on the versions of libraries that it uses. -## Contact +### Telemetry -Join us in slack: [![slack](https://slack.pact.io/badge.svg)](https://slack.pact.io) +In order to get better statistics as to who is using Pact, we collect some anonymous telemetry. The only things we [record](https://docs.pact.io/metrics) are your type of OS, and the version information for the package. No personally identifiable information is sent as part of this request. You can disable telemetry by setting the environment variable `PACT_DO_NOT_TRACK=1`: -or +## Contributing -- Twitter: [@pact_up](https://twitter.com/pact_up) -- Stack Overflow: [stackoverflow.com/questions/tagged/pact](https://stackoverflow.com/questions/tagged/pact) +We welcome contributions to the Pact Python library in many forms. There are many ways to help, from writing code, to providing new examples, to writing documentation, to testing the library and providing feedback. For more information, see the [contributing guide](CONTRIBUTING.md). -[bundler]: http://bundler.io/ -[context manager]: https://en.wikibooks.org/wiki/Python_Programming/Context_Managers -[Pact]: https://docs.pact.io -[Pact Broker]: https://docs.pact.io/pact_broker -[Pact documentation]: https://docs.pact.io/ -[Pact Mock Service]: https://github.com/pact-foundation/pact-mock_service -[Pact specification]: https://github.com/pact-foundation/pact-specification -[Provider States]: https://docs.pact.io/getting_started/provider_states -[pact-provider-verifier]: https://github.com/pact-foundation/pact-provider-verifier -[pyenv]: https://github.com/pyenv/pyenv -[rvm]: https://rvm.io/ -[rbenv]: https://github.com/rbenv/rbenv -[virtualenv]: http://python-guide-pt-br.readthedocs.io/en/latest/dev/virtualenvs/ +[![Table of contributors](https://contrib.rocks/image?repo=pact-foundation/pact-python)](https://github.com/pact-foundation/pact-python/graphs/contributors) diff --git a/RELEASING.md b/RELEASING.md deleted file mode 100644 index da65fa8e2..000000000 --- a/RELEASING.md +++ /dev/null @@ -1,29 +0,0 @@ -# Releasing - -1. Increment the version according to semantic versioning rules in `pact/__version__.py` - -2. To upgrade the versions of `pact-mock_service` and `pact-provider-verifier`, change the - `PACT_STANDALONE_VERSION` in `setup.py` to match the latest version available from the - [pact-ruby-standalone](https://github.com/pact-foundation/pact-ruby-standalone/releases) repository. - -3. Update the `CHANGELOG.md` using: - - `$ git log --pretty=format:' * %h - %s (%an, %ad)' vX.Y.Z..HEAD` - -4. Add files to git - - `$ git add CHANGELOG.md pact/__version__.py` - -5. Commit - - `$ git commit -m "Releasing version X.Y.Z"` - -6. Tag - - `$ git tag -a vX.Y.Z -m "Releasing version X.Y.Z" && git push origin master --tags` - -7. Wait until travis has run and the new tag is available at https://github.com/pact-foundation/pact-python/releases/tag/vX.Y.Z - -8. Set the title to `pact-python-X.Y.Z` - -9. Save diff --git a/biome.json b/biome.json new file mode 100644 index 000000000..d4d0fb13d --- /dev/null +++ b/biome.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.0.4/schema.json", + "assist": { + "actions": { + "source": { "organizeImports": "on" } + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "useEditorconfig": true + } +} diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 000000000..de71106f5 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,109 @@ +# git-cliff configuration file +# https://git-cliff.org/docs/configuration + +[changelog] +# template for the changelog header +header = """ +# Changelog\n +All notable changes to this project will be documented in this file. + + + + + +""" + +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] _{{ timestamp | date(format="%Y-%m-%d") }}_ +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}_({{ commit.scope }})_ {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% if commit.breaking and commit.breaking_description %} + {{ " " }}\ + > {{ + commit.breaking_description + | split(pat="\n") + | join(sep=" ") + | replace(from=" ", to=" ") + | replace(from=" ", to=" ") + | replace(from=" ", to=" ") + | upper_first + }}\ + {% endif %}\ + {% endfor %} +{% endfor %} +{% if github.contributors %}\ + ### Contributors + {% for contributor in github.contributors %}\ + {% if contributor.username and contributor.username is ending_with("[bot]") %}{% continue %}{% endif %} + - @{{ contributor.username }}\ + {% endfor %} +{% endif %} + +""" + +# template for the changelog footer +footer = """ + +""" + +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [] +# render body even when there are no releases to process +# render_always = true + +[git] +tag_pattern = "^(v.*|pact-python/.*)$" +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Remove the PR number added by GitHub when merging PR in UI + { pattern = '\s*\(#([0-9]+)\)$', replace = "" }, + # Check spelling of the commit with https://github.com/crate-ci/typos + { pattern = ".*", replace_command = "typos --write-changes -" }, +] +# regex for parsing and grouping commits +commit_parsers = [ + # Ignore deps commits from the changelog + { message = "^(chore|fix)\\(deps.*\\)", skip = true }, + { message = "^chore: update changelog.*", skip = true }, + # Group commits by type + { group = "🚀 Features", message = "^feat" }, + { group = "🐛 Bug Fixes", message = "^fix" }, + { group = "🚜 Refactor", message = "^refactor" }, + { group = "⚡ Performance", message = "^perf" }, + { group = "🎨 Styling", message = "^style" }, + { group = "📚 Documentation", message = "^docs" }, + { group = "🧪 Testing", message = "^test" }, + { group = "◀️ Revert", message = "^revert" }, + { group = "⚙️ Miscellaneous Tasks", message = "^chore" }, + { group = "� Other", message = ".*" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = false +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +# Ignore any changes from the CLI and FFI sub-projects +exclude_paths = ["pact-python-cli/", "pact-python-ffi/"] + +[remote.github] +owner = "pact-foundation" +repo = "pact-python" diff --git a/committed.toml b/committed.toml new file mode 100644 index 000000000..c6cbb305d --- /dev/null +++ b/committed.toml @@ -0,0 +1,27 @@ +#:schema https://raw.githubusercontent.com/crate-ci/committed/refs/heads/master/config.schema.json +## Configuration for committed +## +## See + +style = "conventional" + +line_length = 80 +merge_commit = false +no_fixup = false +subject_capitalized = false + +allowed_types = [ + "chore", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", +] + +# The author string is of the form `Name `. We want to ignore all bots +# which typically have are ofthe form `some-name[bot] `. +ignore_author_re = "(?i)^.*\\[bot\\] <.*>$" diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..4affbbdf3 --- /dev/null +++ b/conftest.py @@ -0,0 +1,46 @@ +""" +Global PyTest configuration. + +This file is automatically loaded by PyTest before running any tests and is used +to define global fixtures and command line options. Command line options can +only be defined in this file. +""" + +from __future__ import annotations + +import pytest + + +def pytest_addoption(parser: pytest.Parser) -> None: + """ + Define additional command line options for the Pact examples. + + Args: + parser: + Parser used to register CLI options for the tests. + """ + parser.addoption( + "--broker-url", + help=( + "The URL of the broker to use. If this option has been given, the container" + " will _not_ be started." + ), + type=str, + ) + parser.addoption( + "--container", + action="store_true", + help="Run tests using a container", + ) + + +def pytest_runtest_setup(item: pytest.Item) -> None: + """ + Hook into the test setup phase to apply container markers. + + Args: + item: + Pytest item under execution, used to inspect markers and options. + """ + if "container" in item.keywords and not item.config.getoption("--container"): + pytest.skip("need --container to run this test") diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index d48d4379c..000000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -ARG PY -ARG ALPINE=3.17 -FROM python:${PY}-alpine${ALPINE} - -ENV PIP_ROOT_USER_ACTION=ignore - -WORKDIR /home - -COPY requirements_dev.txt . - -RUN apk update \ - && apk upgrade \ - && apk add --no-cache --update gcc build-base linux-headers musl-locales \ - && pip install --progress-bar=off --upgrade pip \ - && pip install --progress-bar=off --upgrade psutil \ - && pip install --progress-bar=off --use-pep517 -r requirements_dev.txt - -# We can't do ENV manipulation to remove the . for tox, so need another var -ARG TOXPY -ENV TOXPY="${TOXPY}" -CMD ["sh", "-c", "tox -e py${TOXPY}-{test,install}"] diff --git a/docker/README.md b/docker/README.md deleted file mode 100644 index 40c8ca230..000000000 --- a/docker/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Introduction - -This is for contributors who want to make changes and test for all different -versions of python currently supported. If you don't want to set up and install -all the different python versions locally (and there are some difficulties with -that) you can just run them in docker using containers. - -# Setup - -To build a container say for Python 3.11, change to the root directory of the -project and run: - -```bash -(export PY=3.11 && docker build --build-arg PY="$PY" --build-arg TOXPY="$(sed 's/\.//' <<< "$PY")" -t pactfoundation:python${PY} -f docker/Dockerfile .) -``` - -This uses an Alpine based image (currently 3.17), which is available as of -2023-04 for Python versions 3.7 - 3.11. - -Note: To run tox, the Python version without the '.' is required, i.e. '311' -instead of '3.11', so some manipulation with `sed` is used to remove the '.' - -To build for Python versions which require a different Alpine image, such as if -trying to build against Python 3.6, an extra `ALPINE` arg can be provided: - -```bash -(export PY=3.6 && docker build --build-arg PY="$PY" --build-arg TOXPY="$(sed 's/\.//' <<< "$PY")" --build-arg ALPINE=3.15 -t pactfoundation:python${PY} -f docker/Dockerfile .) -``` - -To then run the tests and exit: - -```bash -docker run -it --rm -v "$(pwd)":/home pactfoundation:python3.11 -``` - -If you need to debug you can change the command to: - -```bash -docker run -it --rm -v "$(pwd)":/home pactfoundation:python3.11 sh -``` - -This will open a container with a prompt. From the `/home` location in the -container you can run the same tests manually: - -```bash -tox -e py311-{test,install} -``` - -In all the above if you need to run a different version change -`py311`/`python3.11` where appropriate. Or you can run the convenience script -to build: - -```bash -docker/build.sh 3.11 -``` - -where `3.11` is the python environment version. diff --git a/docker/build.sh b/docker/build.sh deleted file mode 100755 index a3a0cba0d..000000000 --- a/docker/build.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/sh - -set -euo pipefail - -if [ $# -lt 1 ]; then - echo "Usage: $0 PYTHON_VERSION [ALPINE_VERSION]" - echo - echo "Example:" - echo "$0 3.11 Build using Python 3.11, default Alpine" - echo "$0 3.6 3.16 Build using Python 3.6, Alpine 3.15" - exit 1 -fi - -PY=$1 -ALPINE=${2:-3.17} -echo "Building env for Python: ${PY}, Alpine: ${ALPINE}" - -DOCKER_IMAGE="pactfoundation:python${PY}" -DOCKER_FILE="docker/Dockerfile" - -docker build \ - --build-arg PY="$PY" \ - --build-arg TOXPY="$(sed 's/\.//' <<< "$PY")" \ - --build-arg ALPINE="${ALPINE}" \ - -t "$DOCKER_IMAGE" -f "$DOCKER_FILE" . - -echo -echo "Image successfully built and tagged as: ${DOCKER_IMAGE}" diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 000000000..61202609a --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,15 @@ + + +- [Home](README.md) + - [Consumer](consumer.md) + - [Provider](provider.md) + - [Logging](logging.md) + - [Releases](releases.md) + - [Migration Guide](MIGRATION.md) + - [Changelog](CHANGELOG.md) + - [Contributing](CONTRIBUTING.md) +- [Examples](examples/) +- [API Documentation](api/) +- [`pact-ffi`](pact-python-ffi/) +- [`pact-cli`](pact-python-cli/) +- [Blog](blog/index.md) diff --git a/docs/blog/.authors.yml b/docs/blog/.authors.yml new file mode 100644 index 000000000..fb9e22cfd --- /dev/null +++ b/docs/blog/.authors.yml @@ -0,0 +1,11 @@ +--- +authors: + JP-Ellis: + name: Joshua Ellis + description: | + Joshua Ellis is a software engineer at SmartBear and open-source + contributor. He has been helping upgrade the Pact Python library to + v3 which will make use of the Rust FFI library. + avatar: https://gravatar.com/avatar/d82f8662c8eadcbfef09de9872b3f060ce4ead0fbae2e58a94435f31fcd00e55?s=512 + slug: jp-ellis + url: https://jpellis.me diff --git a/docs/blog/index.md b/docs/blog/index.md new file mode 100644 index 000000000..05761ac57 --- /dev/null +++ b/docs/blog/index.md @@ -0,0 +1 @@ +# Blog diff --git a/docs/blog/posts/2024/04-11 a sneak peek into the pact python future.md b/docs/blog/posts/2024/04-11 a sneak peek into the pact python future.md new file mode 100644 index 000000000..72bf1e83d --- /dev/null +++ b/docs/blog/posts/2024/04-11 a sneak peek into the pact python future.md @@ -0,0 +1,76 @@ +--- +authors: + - JP-Ellis +date: + created: 2024-04-11 +--- + +# A Sneak Peek into the Pact Python Future + +We are thrilled to announce the release of [Pact Python `v2.2`](https://github.com/pact-foundation/pact-python/releases/tag/v2.2.0), a significant milestone that not only improves upon the existing features but also offers an exclusive preview into the future of contract testing with Python. + +## A Glimpse Ahead with `pact.v3` + +The work is taking shape in a branch-new module – `pact.v3` – that serves as an early preview of what will become Pact Python `v3`. This will provide full support for Pact Specifications `v3` and `v4`. + +This new version harnesses the power of Rust's foreign function interface (FFI) library, promising enhanced performance and reliability. It will also make it easier to incorporate upstream changes in the future. Although it's just a sneak peek, it's an open invitation for you to explore what's coming and contribute to shaping its final form. + + + +## Your Feedback Is Invaluable + +The journey toward perfection is never solitary. We count on your insights and experiences to refine our offerings. If you run into any hiccups or have thoughts you'd like to share: + +- Report issues on our GitHub page: [Pact Python Issues](https://github.com/pact-foundation/pact-python/issues). +- Join the conversation on GitHub discussions: [Pact Python Discussions](https://github.com/pact-foundation/pact-python/discussions). +- Connect with us on Slack: [Pact Foundation Slack](https://slack.pact.io/). + +We eagerly await your input! + +## The Roadmap Ahead + +Transitioning to a new version can be daunting; thus, we've planned a staged migration: + +### :construction: Stage 1 (from v2.2) + +- The existing library remains operational with continued support for minor updates. +- The new `pact.v3` is available for trial but should be used cautiously as changes are expected. +- It's not recommended for production use yet, but feedback from experimentation is encouraged. +- Expect [`PendingDeprecationWarning`](https://docs.python.org/3/library/exceptions.html#PendingDeprecationWarning) alerts when using the current library. + +### :hammer_and_wrench: Stage 2 (from v2.3, to be confirmed) + +- The `pact.v3` module is anticipated to stabilize and we urge users to start planning their migration. +- Comprehensive migration guidance will be provided for a seamless transition. +- More assertive [`DeprecationWarning`](https://docs.python.org/3/library/exceptions.html#DeprecationWarning) notifications will prompt users to switch to the new module. +- This phase will provide ample time, likely spanning a few months, for users to adapt. + +### :rocket: Stage 3 (from v3) + +- The `pact.v3` module graduates to simply `pact`, signaling its readiness as the primary library. + - Migrators from `pact.v3` can expect minimal effort adjustments – mostly a find-and-replace task from `pact.v3` to `pact`. + - Any necessary breaking changes identified during Stage 2 will be implemented, with detailed guidance provided. +- The original library moves under the `pact.v2` umbrella. + - This significant shift means that code written for Pact Python v2 will require attention to function with `v3`. + - Users loyal to `v2` must update their imports to accommodate the new `pact.v2` scope. + - With its move, `pact.v2` steps into the sunset of its lifecycle, focusing on critical fixes until its eventual retirement. + +## Embrace the Evolution + +Ready to dive in? Check out Pact Python `v2.2` today and start exploring what's ahead with `pact.v3`. As developers and testers, your role in this evolution is pivotal. By engaging with this preview and sharing your findings, you help us refine and perfect the tools you rely on. + +We want to make this transition as smooth as possible for our community. We encourage you to explore, test drive the new features, and share your experiences. + +Stay tuned for more updates as we progress through each stage of this exciting journey! + +Lastly, a big thank you to all our contributors! + +Happy testing! + +---- + +/// define +1 August 2025 + +- With the release of Pact Python `v3`, some hyperlinks have been removed from this blog post as they are no longer relevant. +/// diff --git a/docs/blog/posts/2024/05-02 integrating rust ffi with pact python.md b/docs/blog/posts/2024/05-02 integrating rust ffi with pact python.md new file mode 100644 index 000000000..8289964be --- /dev/null +++ b/docs/blog/posts/2024/05-02 integrating rust ffi with pact python.md @@ -0,0 +1,187 @@ +--- +authors: + - JP-Ellis +date: + created: 2024-05-02 +--- + +# Integrating Rust FFI with Pact Python + +In the [forthcoming release of Pact Python version 3](./04-11 a sneak peek into the pact python future.md), we're excited to be integrating our library with the ['Rust core'](https://github.com/pact-foundation/pact-reference), a Rust-based library that encapsulates Pact's fundamental operations for both consumers and providers. Known for its high performance and safety guarantees, [Rust](https://rust-lang.org) enables us to enhance the robustness and efficiency of our implementation. This move also promises simplified maintenance and scalability for future iterations of both the Pact Python library, and the [broader Pact ecosystem](https://docs.pact.io/diagrams/ecosystem). + +At its essence, this Rust-powered engine handles critical tasks such as parsing and serializing Pact files, matching requests with responses, and generating new Pact contracts. It provides mocking capabilities to simulate a provider when verifying a consumer, and equally acts in reverse when replaying consumer requests against a provider. By adopting this shared core logic from Rust, we will achieve uniformity across all languages implementing Pact while streamlining the integration of enhancements or bug fixes-benefits across our diverse ecosystem. + +In this blog post, I will delve into how this is all achieved. From explaining how [Hatch](https://hatch.pypa.io) is used to compile a binary extension and generate wheels for all supported platforms, to the intricacies of interfacing with the binary library. This information is not required to use Pact Python, but hopes to provide a deeper understanding of the inner workings of the library. + + + +## Bridging Python and Binary Libraries + +Python, known for its dynamic typing and automated memory management, is fundamentally an interpreted language. Despite not having innate capabilities to directly interact with binary libraries, most Python interpreters bridge this gap efficiently. For instance, CPython—the principal interpreter—enables the creation of binary extensions[^binary_extension] and similarly, PyPy—a widely-used alternative—offers comparable functionalities[^pypy]. + +[^binary_extension]: You can find extensive documentation on building extensions for CPython [in the official documentation](https://docs.python.org/3/extending/extending.html). +[^pypy]: Refer to the [PyPy extension-building documentation](https://doc.pypy.org/en/latest/extending.html). + +However, each interpreter has a distinct API tailored for crafting these binary extensions, which unfortunately leads to a lack of universal solutions across different environments. Furthermore, interpreters like [Jython](https://jython.org) and [Pyodide](https://pyodide.org/en/stable/), which are based on Java and WebAssembly respectively, present unique challenges that often preclude the straightforward use of such extensions due to their distinct runtime architectures.[^pyodide] + +[^pyodide]: It would appear that Pyodide [can support C extensions](https://pyodide.org/en/stable/development/new-packages.html), though by and large Pyodide appears to be intended for pure Python packages. + +While it is possible for the extension to contain all the logic, our specific requirement is merely to provide a bridge between Python and the Rust core library. This is the niche that [Python C Foreign Function Interface (CFFI)](https://cffi.readthedocs.io/en/stable/) fills. By parsing a C header file, CFFI automates the generation of extension code needed for Python to interface with the binary library. Consequently, this library can be imported into Python as if it were any standard module—streamlining development and potentially improving performance by leveraging optimized native code. + +Moreover, CFFI offers a simpler and more maintainable approach compared to other methods requiring manual boilerplate code. It abstracts away many of the complexities associated with linking Python to C libraries, making it an attractive choice for developers looking for efficiency and ease of integration. + +## Building the Python Extension + +Pact Python uses the fantastic [Hatch](https://hatch.pypa.io) project management and build system for handling dependencies, project metadata, and generate wheels across all supported platforms. Hatch can be extensively customised to suit the needs of each project through its configuration, plugin system, and ability to define custom interfaces. + +In the case of Pact Python, a [`BuildHookInterface`](https://hatch.pypa.io/1.9/plugins/build-hook/reference/) is defined in [`hatch_build.py`](https://github.com/pact-foundation/pact-python/blob/d6869797b52429252b5d0da4d0fc0079f9d3671c/hatch_build.py) which executes several crucial tasks: + +1. Downloads a specified version of the Rust core library from a designated release on the Pact Foundation's GitHub repository, including the accompanying `pact.h` header file. +2. Utilizes CFFI to create a Python extension module that encapsulates the Rust core library: + + ```python + ffibuilder = cffi.FFI() + with (self.tmpdir / "pact.h").open("r", encoding="utf-8") as f: + ffibuilder.cdef(f.read()) # (1) + ffibuilder.set_source( + "_ffi", # (2) + "\n".join([*includes, '#include "pact.h"']), + libraries=["pact_ffi", *extra_libs], # (3) + library_dirs=[str(self.tmpdir)], # (4) + ) + output = Path(ffibuilder.compile(verbose=True, tmpdir=str(self.tmpdir))) # (5) + shutil.copy(output, PACT_ROOT_DIR / "v3") + ``` + + 1. The `cdef` method processes the contents of `pact.h`, creating necessary declarations for the Python extension. + 2. Names the extension module `_ffi`, which is subsequently importable in Python via `import _ffi`. + 3. Details libraries to be linked, including `pact_ffi` and platform-specific additional libraries (`extra_libs`) as needed. + 4. Defines the directory that holds the Rust code library. + 5. Compiles the extension module and then relocates it to the Pact Python project directory. + +Upon completion of these steps, Hatch produces a Python extension module that interfaces seamlessly with the Rust core library. It will have a filename like `src/pact/v3/_ffi.cpython-312-darwin.so` (for CPython 3.12 on macOS) which can be used just as any other Python module. That is, the binary `_ffi` file can be imported in the same way as one would import a regular `.py` file. + +## Using the CFFI Extension + +With the Python extension module built, developers have direct access to interact with the Rust core library from their Python code. This is made possible through two main components generated by CFFI: + +1. `lib`, which provides access to functions and data structures from the Rust core library; +2. `ffi`, which offers additional utilities on the Python side for interfacing with these Rust components. + +Let's look at a simple example of using the CFFI extension to invoke the `pactffi_version` function from the Rust core library: + +```python +from _ffi import lib, ffi + +version = lib.pactffi_version() # (1) +version = ffi.string(version) # (2) +if isinstance(version, bytes): # (3) + version = version.decode("utf-8") +``` + +1. Call the `pactffi_version` function from Rust, which returns a pointer to a null-terminated string. This is represented in Python as a `cdata 'char *'` object. +2. Convert the pointer to a Python string, or bytes if necessary, using the `ffi.string` method. +3. Decode the bytes to a string if needed. + +While the process is reasonably straightforward, it does require some boilerplate code to handle the type conversions. To simplify this, we've wrapped each function from the Rust core library in a simple Python function that performs these conversion automatically. You can find these wrapper functions in the [`ffi` module](https://github.com/pact-foundation/pact-python/blob/d6869797b52429252b5d0da4d0fc0079f9d3671c/src/pact/v3/ffi.py). For example, the `version` function is implemented as follows: + +```python +def version() -> str: + """ + Return the version of the pact_ffi library. + + [Rust `pactffi_version`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_version) + + Returns: + The version of the pact_ffi library as a string, in the form of `x.y.z`. + """ + v = ffi.string(lib.pactffi_version()) + if isinstance(v, bytes): + return v.decode("utf-8") + return v +``` + +The majority of the Rust core library functions return some trivial data types (booleans and integers) which are transparently handled by CFFI without the need for additional conversions. However, there typically is still a need to appropriately manage conversion of arguments into the expected types. A typical pattern will be converting an `str | None` into a `cdata 'char *'`, where `None` is represented as a null pointer: + +```python +def foobar(value: str | None) -> bool: + return lib.foobar(value.encode("utf-8") if value else ffi.NULL) # (1) +``` + +1. The encoding of the string to UTF-8 ensures that the string is correctly represented in the Rust core library. + +### Error Handling + +Handling errors across programming languages can be challenging due to differences in error handling mechanisms. The Rust programming language has two methods of handling unexpected errors: + +1. **Panicking**: This typically occurs when a function encounters an unrecoverable error and terminates the program. The Rust core library handles panics by catching them before they propagate to the Python interpreter, and therefore they can be safely ignored. + +2. **Result**: This is a more structured approach whereby a function can return either `Ok(value)` or `Err(error)` to indicate success or failure. + +It is unfortunately difficult for the C foreign function interface to handle Rust's `Result` type directly. Instead, we've opted to using return codes, either in the form of a boolean or an integer, to indicate success or failure. This is a common pattern in C libraries and is easily translated into Python: + +```python +def write_pact_file( + mock_server_handle: PactServerHandle, + directory: str | Path, + *, + overwrite: bool, +) -> None: + ret: int = lib.pactffi_write_pact_file( + mock_server_handle._ref, + str(directory).encode("utf-8"), + overwrite, + ) + if ret == 0: + return # (1) + if ret == 1: + msg = ( + f"The function panicked while writing the Pact for {mock_server_handle} in" + f" {directory}." + ) + elif ret == 2: + msg = ( + f"The Pact file for {mock_server_handle} could not be written in" + f" {directory}." + ) + else: + msg = ( + "An unknown error occurred while writing the Pact for" + f" {mock_server_handle} in {directory}." + ) + raise RuntimeError(msg) +``` + +1. A return code of `0` indicates success, and the function returns without raising an exception. Other return codes indicate different types of errors, which are then translated into Python exceptions. + +By ensuring that the return codes are correctly handled, we can ensure that end-users are aware of any issues that arise during the execution of the Rust core library functions in a Pythonic manner. + +### Memory Management + +Memory management is another critical aspect to consider when interfacing with binary libraries. Rust's memory model is based on ownership and borrowing, which ensures memory safety and eliminates the need for manual memory management. When interfacing with other languages though, Rust cannot guarantee memory safety, and additional care must be taken to prevent memory leaks. Python, on the other hand, relies on garbage collection to manage memory automatically, which works by checking whether an object is still reachable and deallocating it if not. + +In the case of the Rust core library, the ability to deallocate memory is provided by specific functions such as `pactffi_string_delete`. Python also offers a mechanism to hook into the garbage collection process using the `__del__` method. A good example of this is the `OwnedString` class from the `ffi` module, which automatically deallocates memory when the object is no longer reachable: + +```python +class OwnedString(str): + def __new__(cls, ptr: cffi.FFI.CData) -> Self: + s = ffi.string(ptr) + return super().__new__( + cls, + s if isinstance(s, str) else s.decode("utf-8"), + ) + + def __del__(self) -> None: + lib.pactffi_string_delete(self._ptr) +``` + +The `__del__` method is called[^del_exceptions] when the object is about to be deallocated[^del_no_guarantee], allowing us to free the memory associated with the string. This ensures that memory is managed correctly and prevents potential memory leaks. + +[^del_exceptions]: There are some unique circumstances where `__del__` may not be called, such as when the Python interpreter is shutting down. +[^del_no_guarantee]: Python does not provide guarantees on when `__del__` will be called, so it is not recommended to rely on it for critical cleanup tasks. Instead, the `__enter__` and `__exit__` methods should be used to guarantee timely cleanup. + +## Conclusion + +Integrating Pact Python with the Rust FFI represents a significant step towards enhancing the robustness and efficiency of our library. With the release of version 3 of Pact Python, it is our hope that the community will greatly benefit from the improved performance provided by the Rust core library. + +It is our hope that this blog post also helps to shed some light on the inner workings of the library, whether you are a Pact user who is curious about how the library functions, or a developer looking to contribute to the project. diff --git a/docs/blog/posts/2024/07-26 asynchronous message support.md b/docs/blog/posts/2024/07-26 asynchronous message support.md new file mode 100644 index 000000000..2214841c6 --- /dev/null +++ b/docs/blog/posts/2024/07-26 asynchronous message support.md @@ -0,0 +1,193 @@ +--- +authors: + - JP-Ellis +date: + created: 2024-07-26 +--- + +# Asynchronous Message Support + +We are excited to announce that support for verifying asynchronous message interactions has been added in the recent [release of Pact Python version 2.2.1](https://github.com/pact-foundation/pact-python/releases/tag/v2.2.1). To explore this feature, use the [`pact.v3`][pact] module. A huge shoutout goes to [Val Kolovos](https://github.com/valkolovos) who contributed this feature across two very large PRs ([#714](https://github.com/pact-foundation/pact-python/pull/714) and [#725](https://github.com/pact-foundation/pact-python/pull/725)). This represents a significant step forward in the capabilities of Pact Python and on the road to full support for the Pact specification. + +Asynchronous messages play a crucial role in building resilient and scalable systems. They allow services to communicate with each other without blocking, which can be particularly useful when the sender and receiver are not always available at the same time. However, verifying these interactions is challenging due to the wide variety of messaging systems and protocols. + +Pact simplifies this process by focusing on the content of the messages rather than their transport mechanisms. This approach allows defining expected message exchanges and verifying their adherence independently of messaging systems and protocols. For a more comprehensive view of non-HTTP contract testing, have a look over at [docs.pact.io](https://docs.pact.io/getting_started/how_pact_works#non-http-testing-message-pact). The Pact specification provides a way to verify these interactions, but until now, Pact Python support for this feature was incomplete at best. + + + +We are thrilled about this new feature and eager to see how our community will leverage it in their projects! Please try out asynchronous message support while it's still in preview mode, as your feedback is invaluable in shaping its final release. + +Your feedback will help us refine and prefect this feature. You can provide feedback through any of these channels: + +- Report issues on our GitHub page: [Pact Python Issues](https://github.com/pact-foundation/pact-python/issues). +- Join discussions on GitHub: [Pact Python Discussions](https://github.com/pact-foundation/pact-python/discussions). +- Connect with us on Slack: [Pact Foundation Slack](https://slack.pact.io/). + +Thank you for your continued support! + +## Consumer Example + +Pact is a consumer-driven contract testing tool, and so the consumer defines the expectations of the message. Within the context of asynchronous messages, the consumer is the service that processes the message and might be referred to as the _subscriber_. + +Consider an example where a consumer service is responsible for asynchronously processing requests to delete a user from the database and delete associated files. The Python client might listen for messages from AWS SQS and process them using a function like this: + +```python +from typing import Any + +import boto3 + +QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue" + + +def delete_user(user_id: str) -> bool: + # Delete user from database + # Delete associated files + return True + + +def process_message(message: dict[str, Any]) -> bool: + if message.get("action") == "delete_user": + user_id = message["user_id"] + return delete_user(user_id) + return False + + +def main(): + sqs = boto3.client("sqs") + + response = sqs.receive_message(QueueUrl=queue_url) + for message in response.get("Messages", []): + if process_message(message): + sqs.delete_message( + QueueUrl=queue_url, + ReceiptHandle=message["ReceiptHandle"], + ) +``` + +In this example, the `process_message` function processes messages from an SQS queue and calls the `delete_user` function to delete the user from the database and associated files. The `main` function listens for messages from the SQS queue and processes them using the `process_message` function. + +Here’s an example of a Pact test for this consumer: + +```python +import json + +from pact import Pact + +from my_consumer import process_message + +def handler(body: str | bytes | None, metadata: dict[str, Any]) -> None: + message = json.loads(body) + process_message(message) + +pact = Pact( + consumer="deleteUserService", + provider="someProvider", +).with_specification("V3") # (1) + +( + pact + .upon_receiving("a request to delete a user", "Async") + .with_body( + json.dumps({ + "action": "delete_user", + "user_id": "123", + }) + ) # (2) +) + +pact.verify(handler, "Async") +``` + +1. Support for asynchronous messages starts in version 3 of the Pact specification. +2. No `will_respond_with` method exists for asynchronous messages since there’s no response expected. + +This example highlights how the verification of asynchronous messages differs from HTTP interaction. As the transport layer is abstracted away, a `handler` function is required to parse the raw message string or bytes, and pass it to the underlying function that processes the message. + +The `handler` would also typically be responsible for mocking the underlying systems that the consumer interacts with, such as the database or file system. This allows the consumer to be tested in isolation, without relying on external services. Furthermore, the mocked systems can then be inspected to verify that the consumer has performed the expected actions. + +## Provider Example + +For context of asynchronous messages, the provider is the service that sends the message and might be referred to as the _publisher_ or _producer_. Since the contract is defined by the consumer, the Pact provider test simply has to verify that the messages it sends meet the expectations of the consumer. + +As the underlying protocol is abstracted away, Pact uses a local HTTP server to receive the messages that the provider sends. The provider test for the above consumer might look something like this: + +```python +from pact import Verifier + +class Provider: + """ + A simple HTTP provider that sends messages to the consumer. + + This would typically use the same underlying functions that would generate messages, except that instead of being sent into the message queue, they are sent to the consumer's HTTP server. + """ + +provider = Provider() + +( + Verifier() + .set_info("someProvider", url=provider.url) # (1) + .set_source("/path/to/pacts") + .set_state(provider.state_url) # (2) + .add_transport( # (3) + protocol="message", + path="/_pact/message", + ) + ) +``` + +1. The provider URL is required, but is only used if the Pact being verified contains both HTTP and message interactions. It is not used for message interactions, and should the Pact not contain any HTTP interactions, the endpoint need not be active. +2. The provider state URL is required to ensure the provider is in the correct state. If the provider is entirely stateless, this can be omitted. +3. This path is used by Pact to ensure that the provider is in the correct state before sending the message. + +Those familiar with HTTP interactions will notice that the process is very similar, with the key difference of the additional `add_transport` method. This configures a simple HTTP endpoint which Pact can use to prompt the provider to send a specific message. The following sequence diagram illustrates the flow of the provider test: + +```mermaid +sequenceDiagram + participant Pact as Pact + participant T as Test + participant P as Provider + + Pact->>Pact: Read source(s) + Pact->>T: Set provider state(s) + Pact->>T: Trigger message generation + T->>+P: Call provider + P->>T: Generate message + T->>Pact: Forward message
over HTTP + Pact->>Pact: Verify message +``` + +At present, it is the responsibility of the end user to set up the provider endpoint middle-man to access the message triggers; however, future versions of Pact Python will abstract this away thereby reducing the test boilerplate required. The payloads are: + +1. Trigger from Pact to the provider to generate a message: + + ```http + POST /_pact/message HTTP/1.1 + Content-Type: application/json + + { + "description": "a request to delete a user", + } + ``` + +2. Response expected from the provider: + + ```http + HTTP/1.1 200 OK + Content-Type: application/json + Pact-Message-Metadata: + + { + "action": "delete_user", + "user_id": "123", + } + ``` + + Some queueing systems allow for metadata to be attached to messages and may be required as part of the Pact. If that is the case, the metadata generated by the provider can be passed through the `Pact-Message-Metadata` header as a base-64 encoded string of the underlying JSON object. + +--- + +/// define +1 August 2025 + +- With the release of Pact Python `v3` and the splitting of the CLI and FFI into standalone packages, some hyperlinks and code snippets have been updated to point to the new locations. The _text_ has been kept unchanged to preserve the original context and intent of the post. +/// diff --git a/docs/blog/posts/2024/12-30 functional arguments.md b/docs/blog/posts/2024/12-30 functional arguments.md new file mode 100644 index 000000000..8e8cc439e --- /dev/null +++ b/docs/blog/posts/2024/12-30 functional arguments.md @@ -0,0 +1,340 @@ +--- +authors: + - JP-Ellis +date: + created: 2024-12-30 +--- + +# Functional Arguments + +Today marks the [release of Pact Python version 2.3.0](https://github.com/pact-foundation/pact-python/releases/tag/v2.3.0). Among the many incremental improvements, the most significant is the [support of functional arguments](https://github.com/pact-foundation/pact-python/pull/890). This feature provides an improved user experience for providers, and also introduces several breaking changes to the `pact.v3` preview. + +If you just want to update your existing code to the latest version without any other changes, you can skip to the [Breaking Changes TL;DR](#breaking-changes-tldr) section. Otherwise, key new features now allow you to [define provider states using functions](#functional-state-handler) and [use functions to produce messages](#functional-message-producer). + + +## Breaking Changes TL;DR + +While I highly recommend everyone experiment with the new possibilities that functional arguments bring, if you merely want to update your existing code to the latest version, here is a quick summary of the breaking changes: + +- The `Verifier` initialization now requires a `name` argument which is used to identify the provider in the Pact file. This information was previously given through the `set_info` method which has been removed. The change required is: + +/// tab | Before + +```python +verifier = Verifier() +verifier.set_info("provider_name", ...) +``` + +/// + +/// tab | After + +```python +verifier = Verifier(name="provider_name") +``` + +/// + +- The `Verifier.set_info` method has been entirely removed. Instead, the `Verifier` class now has a `name` attribute which is set during initialization for the provider's name, and the transport information that was previously set is now passed through the `add_transport` method: + +/// tab | Before + +```python +verifier = Verifier() +verifier.set_info( + "provider_name", + url="http://localhost:8123", +) +``` + +/// + +/// tab | After + +```python +verifier = Verifier("provider_name") +verifier.add_transport(url="http://localhost:8123") +``` + +/// + +- The `Verifier.set_state` function has been renamed to `Verifier.state_handler`. Furthermore, if you have already set up a custom endpoint to handle provider state changes, you will now need to explicitly state whether your endpoint expects data to be passed through the query string or through a `POST` body: + +/// tab | Before + +```python +verifier = Verifier() +verifier.set_state("http://localhost:8123/provider-states") +``` + +/// + +/// tab | After + +```python +verifier = Verifier() +verifier.state_handler( + "http://localhost:8123/provider-states", + body=False, # the previous default must be explicitly set +) +``` + +/// + +## Functional State Handler + +When a Pact interaction is to be verified, the consumer will often expect the provider to be in a particular state. For example, a consumer might want to fetch a specific user's details, and therefore the provider must be in a state where that user exists. The user experience prior to version 2.3.0 was less than ideal: the developers behind the provider had to set up a custom endpoint to handle the state changes, and then pass the URL of that endpoint to the `Verifier` object. + +The new `state_handler` method replaces the `set_state` method and simplifies this process significantly by allowing functions to be called to set up and tear down the provider state. For example, the following code snippet demonstrates how to set up a state handler that uses a custom endpoint to handle the provider state: + +/// details | Example + +```python +from pact import Verifier + +def provider_state_callback( + name: str, # (1) + action: Literal["setup", "teardown"], # (2) + parameters: dict[str, Any] | None, # (3) +) -> None: + """ + Callback to set up and tear down the provider state. + + Args: + name: + The name of the provider state. For example, `"a user with ID 123 + exists"` or `"no users exist"`. + + action: + The action to perform. Either `"setup"` or `"teardown"`. The setup + action should create the provider state, and the teardown action + should remove it. + + parameters: + If the provider state has additional parameters, they will be + passed here. For example, instead of `"a user with ID 123 exists"`, + the provider state might be `"a user with the given ID exists"` and + the specific ID would be passed in the params. + """ + ... + +def test_provider(): + verifier = Verifier("provider_name") + verifier.state_handler(provider_state_callback, teardown=True) +``` + +1. The `name` parameter is the name of the provider state. For example, `"a user with ID 123 exists"` or `"no users exist"`. If you instead use a mapping of provider state names to functions, this parameter is not passed to the function. +2. The `action` parameter is either `"setup"` or `"teardown"`. The setup action should create the provider state, and the teardown action should remove it. If you specify `teardown=False`, then the `action` parameter is _not_ passed to the callback function. +3. The `parameters` parameter is a dictionary of additional parameters that the provider state requires. For example, instead of `"a user with ID 123 exists"`, the provider state might be `"a user with the given ID exists"` and the specific ID would be passed in the `parameters` dictionary. Note that `parameters` is always present, but may be `None` if no parameters are specified by the consumer. + +/// + +The function arguments must include the relevant keys from the [`StateHandlerArgs`][pact.types.StateHandlerArgs] typed dictionary. Pact Python will then intelligently determine how to pass the arguments in to your function, whether it be through positional or keyword arguments, or through variadic arguments. + +This snippet showcases a way to set up the provider state with a function that is fully parameterized. The `state_handler` method also handles the following scenarios: + +- If teardowns are never required, then one should specify `teardown=False` in which case the `action` parameter can be omitted from the signature of the callback function. This is useful when the provider state does not require any cleanup after the test has run. + + /// details | Example + + ```python + from pact import Verifier + + def provider_state_callback( + name: str, + parameters: dict[str, Any] | None, + ) -> None: + ... + + def test_provider(): + verifier = Verifier("provider_name") + verifier.state_handler(provider_state_callback, teardown=False) + ``` + + /// + +- A mapping can be provided to the `state_handler` method with keys as the provider state names and values as the function to call. This can help to keep the code organized and to avoid a large number of `if` statements in the callback function. + + /// details | Example + + ```python + from pact import Verifier + + def user_state_callback( + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, + ) -> None: + ... + + def no_users_state_callback( + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, + ) -> None: + ... + + def test_provider(): + verifier = Verifier("provider_name") + verifier.state_handler( + { + "a user with ID 123 exists": user_state_callback, + "no users exist": no_users_state_callback, + }, + ) + ``` + + /// + +- Both scenarios can be combined, in which a mapping of provide state names to functions is provided, and the `teardown=False` option is specified. In this case, the function should expect only one argument: the `parameters` dictionary (which itself may be `None`). + + /// details | Example + + ```python + from pact import Verifier + + def user_state_callback( + parameters: dict[str, Any] | None, + ) -> None: + ... + + def no_users_state_callback( + parameters: dict[str, Any] | None, + ) -> None: + ... + + def test_provider(): + verifier = Verifier("provider_name") + verifier.state_handler( + { + "a user with ID 123 exists": user_state_callback, + "no users exist": no_users_state_callback, + }, + teardown=False, + ) + ``` + + /// + +## Functional Message Producer + +In the messaging paradigm, the Pact consumer consumes the message produced by the provider (which is often referred to as the "producer"). As there are many and varied transport mechanisms for messages, Pact approaches the verification of messages in a transport-agnostic way. Previously, the provider would need to define a special HTTP endpoint to generate the message, and then pass the URL of that endpoint to the `Verifier` object. This process was cumbersome, especially considering that most producers do not expose any HTTP endpoints to begin with. + +With the update to 2.3.0, the `Verifier` class has a new `message_handler` method which allows the provider to pass a function that generates the message. This function is called by the `Verifier` object when it needs a message to verify. The following code snippet demonstrates how to set up a message producer that uses a custom endpoint to generate the message: + +/// details | Example + +```python +from pact import Verifier +from pact.types import Message + +def message_producer_callback( + name: str, # (1) + metadata: dict[str, Any] | None, # (2) +) -> Message: + """ + Callback to produce the message that the consumer expects. + + Args: + name: + The name of the message. For example `"request to delete a user"`. + + metadata: + Metadata that is passed along with the message. This could include information about the queue name, message type, creation timestamp, etc. + + Returns: + The message that the consumer expects. + """ + ... + +def test_provider(): + verifier = Verifier("provider_name") + verifier.message_handler(message_producer_callback) +``` + +1. The `name` parameter is the name of the message. For example, `"request to delete a user"`. If you instead use a mapping of message names to functions, this parameter is not passed to the function. +2. The `params` parameter is a dictionary of additional parameters that the message requires. For example, one could specify the user ID to delete in the parameters instead of the message. Note that `params` is always present, but may be `None` if no parameters are specified by the consumer. + +/// + +The function arguments must include the relevant keys from the [`MessageProducerArgs`][pact.types.MessageProducerArgs] typed dictionary. Pact Python will then intelligently determine how to pass the arguments in to your function, whether it be through positional or keyword arguments, or through variadic arguments. + +The output of the callback function should be an instance of the `Message` type. This is a simple [TypedDict][typing.TypedDict] that represents the message that the consumer expects and can be specified as a simple dictionary, or with typing hints through the `Message` constructor: + +/// tab | With typing hints + +```python +from pact.types import Message + +def message_producer_callback( + name: str, + params: dict[str, Any] | None, +) -> Message: + assert name == "request to delete a user" + return Message( + contents=json.dumps({ + "action": "delete_user", + "user_id": "123", + }).encode("utf-8"), + metadata=None, + content_type="application/json", + ) +``` + +/// + +/// tab | Without typing hints + +```python +def message_producer_callback(name, params): + assert name == "request to delete a user" + return { + "contents": json.dumps({ + "action": "delete_user", + "user_id": "123", + }).encode("utf-8"), + "metadata": None, + "content_type": "application/json", + } +``` + +/// + +In much the same way as the `state_handler` method, the `message_handler` method can also accept a mapping of message names to functions or raw messages. The function should expect only one argument: the `metadata` dictionary (which itself may be `None`); or if the message is static, the message can be provided directly: + +/// details | Example + +```python +from pact import Verifier +from pact.types import Message + +def delete_user_message(metadata: dict[str, Any] | None) -> Message: + ... + +def test_provider(): + verifier = Verifier("provider_name") + verifier.message_handler( + { + "request to delete a user": delete_user_message, + "create user": { + "contents": b"some message", + "metadata": None, + "content_type": "text/plain", + }, + }, + ) +``` + +/// + +---- + +/// define +28 March 2025 + +- This blog post was updated on 28 March 2025 to reflect changes to the way functional arguments are handled. Instead of requiring positional arguments, Pact Python now inspects the function signature in order to determine whether to pass the arguments as positional or keyword arguments. It will fallback to passing the arguments through variadic arguments (`*args` and `**kwargs`) if present. This was done specific to allow for functions with optional arguments. + + For this added flexibility, the function signatures must have parameters that align with the [`StateHandlerArgs`][pact.types.StateHandlerArgs] and [`MessageProducerArgs`][pact.types.MessageProducerArgs] typed dictionaries. This allows Pact Python to match a `parameters=...` argument with the `parameters` key in the dictionary. Using an alternative name (e.g., `params`) will not work. + +1 August 2025 + +- With the release of Pact Python `v3` and the splitting of the CLI and FFI into standalone packages, some hyperlinks and code snippets have been updated to point to the new locations. The _text_ has been kept unchanged to preserve the original context and intent of the post. +/// diff --git a/docs/blog/posts/2025/12-04 pact-python-v3-release.md b/docs/blog/posts/2025/12-04 pact-python-v3-release.md new file mode 100644 index 000000000..cd6d18e45 --- /dev/null +++ b/docs/blog/posts/2025/12-04 pact-python-v3-release.md @@ -0,0 +1,157 @@ +--- +authors: + - JP-Ellis +date: + created: 2025-12-04 +--- + +# Announcing: Pact Python v3 + +It's been a couple of months since we released Pact Python v3, and after ironing out a couple of early issues, I think it's finally time to reflect on this milestone and its implications. This post is a look back at the journey, some of the challenges, the people, and the future of this project within the Pact ecosystem. + + + +Pact is an approach to contract testing that sits neatly between traditional unit tests (which check individual components) and end-to-end tests (which exercise the whole system). With Pact, you can verify that your services communicate correctly, without needing to spin up every dependency. By capturing the expected interactions between consumer and provider, Pact allows you to test each side in isolation and replay those interactions, giving you fast, reliable feedback and confidence that your APIs and microservices will work together in the real world. Pact Python brings this powerful workflow to the Python ecosystem, making it easy to test everything from REST APIs to event-driven systems. + +## Looking Back: Why v3? + +Pact has a diverse ecosystem, with SDKs in all major languages. Pact Python was (and still is) the most popular implementation of Pact for Python. As with many of the early Pact SDKs, Pact Python was built on top of the Pact Ruby codebase, as that was _the_ reference implementation of Pact. + +This came with a few problems: + +1. The reference implementation of Pact moved to [Rust](https://github.com/pact-foundation/pact-reference), and development for versions 3 and 4 of the Pact specification took place there, with limited features being backported to Pact Ruby. +2. It required bundling Ruby as part of the Python wheels, which significantly bloated distributions and slowed down Pact Python. +3. The Python code served primarily as a wrapper to calling the Ruby-based CLIs, and some aspects of that implementation were exposed to end-users, such as manually checking process exit codes, resulting in a non-"pythonic" experience. + +As the Pact specification evolved and the needs of our users grew, it became clear that the old architecture was starting to show its age. Supporting new features, keeping up with upstream changes, and maintaining compatibility across platforms was becoming increasingly difficult. + +Version 3 of Pact Python was an opportunity to do things differently. Not only could we move away from the Ruby dependency and unlock support for the latest Pact specifications, but we could also make Pact Python much more "pythonic." This meant embracing modern Python best practices: proper exception handling, context management, full typing, and a more intuitive API. The goal was to make the library feel natural for Python developers, whether they were new to Pact or contract testing veterans. + +With these objectives in mind, the development of v3 commenced. + +## The Journey: From Idea to Release + +Very early in the development of v3, it was clear to me that this was an opportunity to fundamentally rethink the library's architecture. While the core Pact idioms from the broader ecosystem have been retained, the internal flow and structure of Pact Python were comprehensively overhauled. This decision was not made lightly, as it does introduce a burden on end-users; however, I hoped this would provide significant long-term benefits for maintainability, extensibility, and user experience. Now looking back, I do think this was the right decision, and I'm glad that I was allowed to implement these changes even though I was a newcomer to Pact's ecosystem. + +Migrating a large codebase from Pact Python v2 to v3 is an onerous task. Accordingly, considerable effort was invested in ensuring compatibility and a smooth transition. This included the preparation of detailed migration guides, the parallel support of both v2 and v3 for an extended period, and the incorporation of feedback from early adopters who trialed the new version in production environments. The ongoing support for v2 alongside v3 is intended to allow users to migrate incrementally and at their own pace. + +The development process for v3 was iterative and, at times, complex. There were periods of rapid progress, such as the initial successful execution of contract tests using the new Rust core, as well as periods where platform-specific issues or subtle bugs required significant investigation and resolution (sometimes making me question my most basic reasoning abilities). Throughout, the primary objective remained to ensure that the new implementation not only matched the previous feature set, but also delivered tangible improvements in usability, reliability, and performance. + +The support of the PactFlow team at SmartBear was instrumental throughout this process, providing code reviews, testing, and guidance. The broader community also played a crucial role, contributing issues, pull requests, and practical insights that informed many of the design decisions. In particular, feedback and real-world testing from early adopters were invaluable during the preview and stabilization phases, helping to shape the final release. + +## What's New in v3? + +### Faster, Leaner, and More Reliable + +The move to a Rust FFI core is a game changer. Tests run faster, memory usage is lower, and the behaviour is aligned with most Pact SDKs in the ecosystem. I have already noticed significant speed-ups in test suites, and I hope end-users will notice this too. And with full support for both v3 and v4 of the Pact specification, you get access to the latest features, like asynchronous message support and improved matching rules, right out of the box. + +### A Truly Pythonic Experience + +Pact Python v3 is designed to feel like it belongs in the Python ecosystem. The API has been completely reimagined: context management, proper exception handling, and full type hints are now first-class citizens. The new interface is more intuitive, with less boilerplate and clearer error messages. + +Matchers are more expressive and flexible, making it easier to write robust, maintainable tests for even the most complex data structures. Provider state handling is now much more flexible too: you can use plain Python functions to manage test data and state, instead of relying on bespoke HTTP endpoints. + +All of this means writing and verifying contracts should feel natural, whether you're new to Pact or a seasoned pro. + +What does this look like in practice? Here's a side-by-side comparison of a simple Pact test in v2 and v3: + +```python title="Pact Python v2" +from pact.v2 import Consumer, Provider +import requests + +consumer = Consumer('my-web-front-end') +provider = Provider('my-backend-service') + +pact = consumer.has_pact_with(provider, pact_dir='/path/to/pacts') +( + pact + .given('user exists') # (1) + .upon_receiving('a request for user data') + .with_request( + 'GET', + '/users/123', + headers={'Accept': 'application/json'}, + query={'include': 'profile'} + ) + .will_respond_with( + 200, + headers={'Content-Type': 'application/json'}, + body={'id': 123, 'name': 'Alice'} + ) +) + +pact.start_service() # (2) +pact.setup() +response = requests.get(pact.uri + '/users/123') +assert response.json() == {'id': 123, 'name': 'Alice'} +pact.verify() # (3) +pact.stop_service() # (4) +# Pact file is written as part of verify() or when the service stops +``` + +1. Provider states in v2 are simple strings, which can lead to duplication if you need to test similar states with different parameters. +2. The mock service must be started manually before running tests. +3. Verification and Pact file writing are triggered explicitly. +4. The mock service must be stopped manually after tests. + +```python title="Pact Python v3" +from pact import Pact +import requests + +pact = Pact('my-web-front-end', 'my-backend-service') +( + pact + .upon_receiving('a request for user data') + .given('user exists', id=123, name='Alice') # (1) + .with_request('GET', '/users/123') + .with_header('Accept', 'application/json') + .with_query_parameter('include', 'profile') + .will_respond_with(200) + .with_body({'id': 123, 'name': 'Alice'}, content_type='application/json') +) + +with pact.serve() as srv: # (2) + response = requests.get(f"{srv.url}/users/123") + assert response.json() == {'id': 123, 'name': 'Alice'} +pact.write_file('/path/to/pacts') # (3) +``` + +1. In v3, provider states can be parameterized, making it easier to reuse and manage test data across different scenarios. +2. The new `serve()` method provides a more Pythonic and flexible way to run the mock service, automatically handling setup and teardown. +3. Pact files are written explicitly, giving you more control over when and where they are saved. + +### Migration, with You in Mind + +We know big upgrades can be daunting, especially for teams with large codebases. That's why v3 includes a backwards compatibility module: you can keep your old tests running while you gradually adopt the new API, at your own pace. Many of the changes in v3 came directly from user feedback; feature requests, bug reports, and discussions on Slack have all shaped this release. The transition is designed to be as smooth as possible, so you can take advantage of new features without disrupting your workflow. + +## Reflections and Gratitude + +No open source project is a solo effort, and Pact Python v3 is no exception. This release stands on the shoulders of a vibrant community, and I want to take a moment to recognize the many people who have shaped this project. + +**Special thanks to the contributors to the v3 codebase:** + +- **[valkolovos](https://github.com/valkolovos):** for his work on matchers, generators, asynchronous message support, and being an early adopter of Pact Python v3. +- **[Nikhil Arora](https://github.com/Nikhil172913832):** for a number of recent improvements, including improvements to the developer experience. +- **[Amit Singh](https://github.com/amit828as):** for expanding v3 HTTP interaction examples and real-world testing. +- **[Kevin Rohan Vaz](https://github.com/kevinrvaz):** For fixing and improving the v3 verifier. + +I would also like to acknowledge contributors to the (now legacy) v2 codebase and the original project: + +- **Core architecture and early development:** [Elliott Murray](https://github.com/elliottmurray), [Matthew Balvanz](https://github.com/matthewbalvanz-wf). +- **Message pact and provider support:** [Tuan Pham](https://github.com/tuan-pham), [William Infante](https://github.com/williaminfante), [Fabio Pulvirenti](https://github.com/pulphix). +- **Testing and verification:** [Peter Yasi](https://github.com/pyasi), [Simon Nizov](https://github.com/thatguysimon), [mikahjc](https://github.com/mikahjc), [Maciej Olko](https://github.com/m-aciek), [Matt Fellows](https://github.com/mefellows), [Janneck Wullschleger](https://github.com/jawu), [Rory Hart](https://github.com/hartror), [simkev2](https://github.com/SimKev2). +- **Other features, fixes, and support:** [B3nnyL](https://github.com/B3nnyL), [Yousaf Nabi](https://github.com/YOU54F), [Beth Skurrie](https://github.com/bethesque), [Francois Campbell](https://github.com/francoiscampbell), [Serghei Iakovlev](https://github.com/sergeyklay), and many others over the years. + +And certainly not least, a huge thank you to those who have helped with documentation, onboarding, and community support: + +- **Documentation and onboarding:** [Elliott Murray](https://github.com/elliottmurray), [Matthew Balvanz](https://github.com/matthewbalvanz-wf), [Yousaf Nabi](https://github.com/YOU54F), [Beth Skurrie](https://github.com/bethesque), [Matt Fellows](https://github.com/mefellows), [Serghei Iakovlev](https://github.com/sergeyklay). +- **Examples and reliability:** [mikegeeves](https://github.com/mikegeeves), [William Infante](https://github.com/williaminfante), [Artur Neumann](https://github.com/individual-it). +- **Community support and feedback:** and everyone who has opened issues, submitted PRs, or shared their experiences, on Slack, GitHub, and elsewhere! + +## Where to Next? + +With v3 as our new foundation, we are already seeing new features and integrations become possible that would have been out of reach before. If you are using Pact Python in your projects, I would welcome your stories, whether it's a simple 'we're using this', or more feedback on what is working, what could be improved, and what you would like to see next. + +If you are ready to get started, you will find everything you need in the [documentation](https://pact-foundation.github.io/pact-python/), including a [migration guide](https://pact-foundation.github.io/pact-python/MIGRATION/) for those moving from v2. The [GitHub repository](https://github.com/pact-foundation/pact-python) is always open for issues, discussions, and contributions. If you are new to Pact entirely, you can read more about it on [`pact.io`](https://pact.io/). + +Thank you for being part of this journey. Here's to a new chapter in contract testing for Python. Happy testing! diff --git a/docs/consumer.md b/docs/consumer.md new file mode 100644 index 000000000..934b1e217 --- /dev/null +++ b/docs/consumer.md @@ -0,0 +1,377 @@ +# Consumer Testing + +Pact is a consumer-driven contract testing tool. The consumer specifies the expected interactions with the provider, which are used to create a contract. This contract is then used to verify that the provider meets the consumer's expectations. + +/// html | div[align="center'] + +```mermaid +sequenceDiagram + box Consumer Side + participant Consumer + participant P1 as Pact + end + box Provider Side + participant P2 as Pact + participant Provider + end + Consumer->>P1: GET /users/123 + P1->>Consumer: 200 OK + Consumer->>P1: GET /users/999 + P1->>Consumer: 404 Not Found + + P1--)P2: Pact Broker + + P2->>Provider: GET /users/123 + Provider->>P2: 200 OK + P2->>Provider: GET /users/999 + Provider->>P2: 404 Not Found +``` + +/// + +The consumer is the client that makes requests, and the provider is the server that responds. In most cases, the consumer is a front-end application and the provider is a back-end service; however, a back-end service may also require information from another service, making it a consumer of that service. + +The core logic is implemented in Rust and exposed to Python through the [core Pact FFI](https://github.com/pact-foundation/pact-reference). This will help ensure feature parity between different language implementations and improve performance and reliability. This also brings compatibility with the latest Pact Specification (v4). + +> [!NOTE] +> +> For asynchronous interactions (e.g., message queues), the consumer refers to the service that processes the messages. This is not covered here, but further information is available in the [Message Pact](https://docs.pact.io/getting_started/how_pact_works#non-http-testing-message-pact) section of the Pact documentation. + +## Writing the Test + +> [!NOTE] +> +> The code below is an abridged version of [this example](./examples/http/requests_and_fastapi/README.md). + +### Consumer Client + +For example, consider a simple API client that interacts with a user provider service. The client has methods to get, create, and delete users. The user data model is defined using a dataclass. + +```python +from dataclasses import dataclass +from datetime import datetime +from typing import Any +import requests + +@dataclass() +class User: # (1) + id: int + name: str + created_on: datetime + +class UserClient: + """Simple HTTP client for interacting with a user provider service.""" + + def __init__(self, hostname: str) -> None: # (2) + self._hostname = hostname + + def get_user(self, user_id: int) -> User: + """Get a user by ID from the provider.""" + response = requests.get(f"{self._hostname}/users/{user_id}") + response.raise_for_status() + data: dict[str, Any] = response.json() + return User( # (3) + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), + ) +``` + +1. The `User` dataclass represents the user data model _as used by the client_. Importantly, this is not necessarily the same as the data model used by the provider. The Pact contract should reflect what the consumer needs, not what the provider actually implements. + +2. The initialiser for the `UserClient` class takes a `hostname` parameter, which is the base URL of the user provider service. This ensures that the client can be easily pointed to the mock service during testing. + +3. Only the fields required by the consumer are included in the `User` dataclass. The provider might return additional fields (e.g., `email`, `last_login`, etc.), but this consumer does not need to know about them and therefore they are ignored in the client implementation. + +### Consumer Test + +The following is a Pact test for the `UserClient` class defined above. It sets up a mock provider, defines the expected interactions, and verifies that the client behaves as expected. + +```python +from pathlib import Path + +import pytest +from pact import Pact, match + +@pytest.fixture +def pact() -> Generator[Pact, None, None]: # (1) + """Set up a Pact mock provider for consumer tests.""" + pact = Pact("user-consumer", "user-provider").with_specification("V4") # (2) + yield pact + pact.write_file(Path(__file__).parent / "pacts") + +def test_get_user(pact: Pact) -> None: + """Test the GET request for a user.""" + response: dict[str, object] = { # (3) + "id": match.int(123), + "name": match.str("Alice"), + "created_on": match.datetime(), + } + ( + pact.upon_receiving("A user request") # (4) + .given("the user exists", id=123, name="Alice") # (5) + .with_request("GET", "/users/123") # (6) + .will_respond_with(200) # (7) + .with_body(response, content_type="application/json") # (8) + ) + + with pact.serve() as srv: # (9) + client = UserClient(str(srv.url)) # (10) + user = client.get_user(123) + assert user.name == "Alice" +``` + +1. A [Pytest fixture](https://docs.pytest.org/en/stable/explanation/fixtures.html) provides a reusable `pact` object for multiple tests. In this case, the fixture creates a [`Pact`][pact.Pact] instance representing the contract between the consumer and provider. The fixture yields the `pact` object to the test function, and after the test completes, writes the generated pact file to the specified directory. + +2. The Pact specification version is set to `"V4"` to ensure compatibility with the latest features and improvements in the Pact ecosystem. Note that this is the default version, so this line is optional unless you want to specify a different version. + +3. The expected response is defined using the `match` module for flexible matching of the response data. Here, the `id` field is expected to be an integer, the `name` field a string, and the `created_on` field a datetime string. The specific values are not important, as long as they match the expected types. + +4. The `upon_receiving` method defines the description of the interaction. This description also uniquely identifies the interaction within the Pact file. + +5. The `given` method sets up the provider state, indicating that the user with ID 123 exists. Pact allows parameters to be passed to the provider state, which can be used to set up the provider in a specific way. Here, the parameters `id=123` and `name="Alice"` are provided, which the provider can use to create the user if necessary. + +6. The `with_request` method defines the expected request that the consumer will make. Here, it specifies that a `GET` request will be made to the `/users/123` endpoint. + +7. The `will_respond_with` method specifies the expected HTTP status code of the response. Here, a `200 OK` status is expected. The `will_respond_with` method also helps separate the request definition from the response definition, improving readability. + +8. The `with_body` method defines the expected body of the response, using the `response` dictionary defined earlier. The `content_type` parameter specifies that the response will be in JSON format. + +9. The `pact.serve()` method starts the mock service, and the `srv` object provides the URL of the mock service. Within this context, any requests made to the mock service will be handled according to the interactions defined on the `pact` object. Once the context is exited, the mock service is stopped, and the interactions are verified to ensure all expected requests were made. + +10. The `UserClient` is instantiated with this URL, and the `get_user` method is called to retrieve the user data. The test asserts that the returned user's name is "Alice". + +The test begins with a Pytest fixture that creates a reusable Pact instance representing the contract between `"user-consumer"` and `"user-provider"`. The expected response is defined using flexible matchers (`match.int()`, `match.str()`, `match.datetime()`) to validate data types rather than exact values, making the test more robust against varying response data. + +The interaction definition includes a description, provider state parameters, request details, and expected response format. Only the required parts of the interaction are specified, rather than an exhaustive specification. For example, the client will typically add additional headers (e.g., `User-Agent`, `Accept`, etc.) to the request, but these are not necessary for the contract and are therefore omitted. Similarly, the provider's response may include additional fields or headers that the consumer will ignore, so these are also not included in the contract. + + The `pact.serve()` context manager starts a mock provider service that handles requests according to the defined interactions, creating a controlled testing environment. The actual client code is then executed against this mock service, ensuring it makes correct requests and handles responses properly. Once the context is exited, the Pact file is automatically written to the specified directory for later provider verification, completing the consumer-driven contract testing cycle. + +> [!WARNING] +> +> A common mistake is to use a generic HTTP client (e.g., `requests`, `httpx`, etc.) to make requests to the mock service within the test. This defeats the purpose of the test, as it does not verify that the client is making the correct requests and handling the responses correctly. + +### Multi-Interaction Testing + +The mock service can handle multiple interactions within a single test. This is useful when you want to test a sequence of requests and responses. For example, a first request might create a background task, a second request might check the status of that task, and a final request retrieves the result. This flow can be tested in a single test function by defining multiple interactions on the `pact` object: + +```python +( + pact.upon_receiving("A request to create a task") + .with_request("POST", "/tasks", body={"type": "long_running"}) + .will_respond_with(202) + .with_header("Location", "/tasks/1/status") +) + +( + pact.upon_receiving("A request to check task status") + .with_request("GET", "/tasks/1/status") + .will_respond_with(200) + .with_body({"status": "completed"}) + .with_headers({ + "Task-ID": "1", + "Location": "/tasks/1/result", + }) +) + +( + pact.upon_receiving("A request to get task result") + .with_request("GET", "/tasks/1/result") + .will_respond_with(200) + .with_body({"result": "Task completed successfully"}) +) +``` + +When the mock service is started with `pact.serve()`, it will handle requests for all defined interactions, ensuring the client code can be tested against a realistic sequence of operations. Furthermore, for the test to pass, all defined interactions must be exercised by the client code. If any interaction is not used, the test will fail. + +## Logging + +To enable logging for debugging and troubleshooting, configure the FFI (Foreign Function Interface) logging using [`pact_ffi.log_to_stderr`][pact_ffi.log_to_stderr]. This is particularly useful when you need to understand what's happening inside the mock service or diagnose issues with your tests. + +The recommended approach is to set up logging in a pytest fixture within your `conftest.py`: + +```python +import pytest +import pact_ffi + +@pytest.fixture(autouse=True, scope="session") +def pact_logging(): + """Configure Pact FFI logging for the test session.""" + pact_ffi.log_to_stderr("INFO") +``` + +For more information on logging configuration, including advanced options and troubleshooting, see the [Logging Configuration](logging.md) page. + +## Mock Service + +Pact provides a mock service that simulates the provider service based on the defined interactions. The mock service is started when the `pact` object is used as a context manager with `pact.serve()`, as shown in the [consumer test](#consumer-test) example above. + +The mock service automatically selects a random free port by default, helping to avoid port conflicts when running multiple tests. You can optionally specify a custom host and port during Pact creation if needed for your testing environment. + +```python +with pact.serve(host="localhost", port=1234) as srv: + client = UserClient(str(srv.url)) + user = client.get_user(123) +``` + +The mock service offers several important features when building your contracts: + +- It provides a real HTTP server that your code can contact during the test and returns the responses you defined. +- You provide the expectations for the requests your code will make, and it asserts the contents of the actual requests made against your expectations. +- If a request is made that does not match one you defined, or if a request from your code is missing, it returns an error with details. + +## Broker + +The above example showed how to test a single consumer; however, without also testing the provider, the test is incomplete. The Pact Broker is a service that allows you to share and manage your contracts between your consumer and provider tests. It acts as a central repository for your contracts, allowing you to publish contracts from your consumer tests and retrieve them in your provider tests. + +Once the tests are complete (and successful), the contracts can be uploaded to the Pact Broker. The provider can then download the contracts and run its own tests to ensure it meets the consumer's expectations. + +The Broker CLI is a command-line tool that can be installed through the `pact-python-cli` package, or directly from the [Pact Standalone](https://github.com/pact-foundation/pact-standalone) releases page. It bundles several standalone CLI tools, including the `pact-broker` CLI client. + +The general syntax for the CLI is: + +```console +pact-broker publish \ + /path/to/pacts/consumer-provider.json \ + --consumer-app-version 1.0.0 \ + --auto-detect-version-properties +``` + +It expects the following environment variables to be set: + +/// define +`PACT_BROKER_BASE_URL` + +- The base URL of the Pact Broker (e.g., `https://test.pactflow.io` if using [PactFlow](https://pactflow.io), or the URL to your self-hosted Pact Broker instance). + +`PACT_BROKER_USERNAME` / `PACT_BROKER_PASSWORD` + +- The username and password for authenticating with the Pact Broker. + +`PACT_BROKER_TOKEN` + +- An alternative to using username and password, this is a token that can be used for authentication (e.g., used with [PactFlow](https://pactflow.io)). + +/// + +## Pattern Matching + +Simple equality checks work for basic scenarios, but realistic tests need flexible matching to handle variable data such as timestamps, IDs, and dynamic content. The `match` module provides matchers that validate data structure and types rather than exact values. + +```python +from pact import match + +# Instead of exact matches that break easily: +response = { + "id": 12345, # Brittle - specific value + "email": "user@example.com", # Fails if email changes + "created_at": "2024-01-15T10:30:00Z" # Breaks on different timestamps +} + +# Use flexible matchers: +response = { + "id": match.int(12345), # Any integer + "email": match.regex("user@example.com", regex=r".+@.+\..+"), + "created_at": match.datetime("2024-01-15T10:30:00Z") +} +``` + +Common matcher types include: + +- **Type matchers**: `match.int()`, `match.str()`, `match.bool()` - validate data types +- **Pattern matchers**: `match.regex()`, `match.uuid()` - validate specific formats +- **Collection matchers**: `match.each_like()`, `match.array_containing()` - handle arrays and objects +- **Date/time matchers**: `match.date()`, `match.time()`, `match.datetime()` - flexible timestamp handling + +Matchers ensure your contracts focus on data structure and semantics rather than brittle exact values, making tests more robust and maintainable. + +For comprehensive documentation and examples, see the [API Reference](api/match/README.md) and the [`match` module documentation][pact.match]. For more about Pact's matching specification, see [Matching](https://docs.pact.io/getting_started/matching). + +## Dynamic Data Generation + +While matchers validate that received data conforms to expected patterns, generators produce realistic test data for responses. The `generate` module provides functions to create dynamic values that change on each test run, making your Pact contracts more realistic and robust. + +```python +from pact import generate + +# Instead of static values in your mock responses +response = { + "user_id": 123, # Always the same + "session_token": "abc-def-123", # Predictable + "created_at": "2024-07-20T14:30:00+00:00" # Never changes +} + +# Use generators for dynamic, realistic data +response = { + "user_id": generate.int(min=1, max=999999), + "session_token": generate.uuid(), + "created_at": generate.datetime("%Y-%m-%dT%H:%M:%S%z") +} +``` + +Generators are particularly useful when: + +- **Testing with fresh data**: Each test run uses different values, helping catch issues with data handling +- **Avoiding test pollution**: Dynamic IDs and tokens prevent tests from accidentally depending on specific values +- **Simulating real conditions**: Generated timestamps, UUIDs, and random numbers better represent actual API behavior +- **Provider state integration**: Using `generate.provider_state()` to inject values from the provider's test setup + +### Common Generators + +```python +from pact import generate + +response = { + # Numeric values with constraints + "user_id": generate.int(min=1, max=999999), + "price": generate.float(precision=2), # 2 total digits + "hex_color": generate.hex(digits=6), # 6-digit hex code + + # String and text data + "username": generate.str(size=8), # 8-character string + "confirmation": generate.regex(r"[A-Z]{3}-\d{4}"), # Pattern-based + + # Identifiers + "session_id": generate.uuid(), # Standard UUID format + "simple_id": generate.uuid(format="simple"), # No hyphens + + # Dates and times + "created_at": generate.datetime("%Y-%m-%dT%H:%M:%S%z"), + "birth_date": generate.date("%Y-%m-%d"), + "start_time": generate.time("%H:%M:%S"), + + # Boolean values + "is_active": generate.bool(), + + # Provider-specific values + "server_url": generate.mock_server_url(), + "dynamic_value": generate.provider_state("${expression}") +} +``` + +### Combining Matchers and Generators + +Matchers and generators work together to create flexible, realistic contracts. Use matchers to validate incoming data and generators to produce dynamic response data: + +```python +# Request validation with matchers +request_body = { + "email": match.regex("user@example.com", regex=r".+@.+\..+"), + "age": match.int(25, min=18, max=100), + "preferences": match.array_containing([match.str("notifications")]) +} + +# Response generation with dynamic data +response_body = { + "id": generate.int(min=100000, max=999999), + "email": match.str("user@example.com"), # Echo back the input + "verification_token": generate.uuid(), # Fresh token each time + "created_at": generate.datetime("%Y-%m-%dT%H:%M:%S%z"), + "profile_url": generate.mock_server_url( + example="/profiles/12345", + regex=r"/profiles/\d+" + ) +} +``` + +This approach ensures your tests validate the correct data structures while generating realistic, varied response data that better simulates real-world API behaviour. diff --git a/docs/img/mascot.svg b/docs/img/mascot.svg new file mode 100644 index 000000000..de7717ad0 --- /dev/null +++ b/docs/img/mascot.svg @@ -0,0 +1,409 @@ + + + + + + + + + + + + diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 000000000..c3285555d --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,123 @@ +# Logging Configuration + +Pact Python uses the Rust FFI (Foreign Function Interface) library for its core functionality. While the Python code uses the standard library `logging` module, the underlying FFI cannot interface with that directly. This page explains how to configure FFI logging for debugging and troubleshooting. + +## Basic Configuration + +The simplest way to configure FFI logging is to use the [`log_to_stderr`][pact_ffi.log_to_stderr] function from the `pact_ffi` module. This directs all FFI log output to standard error. + +```python +import pact_ffi + +pact_ffi.log_to_stderr("INFO") +``` + +### Log Levels + +The following log levels are available (from least to most verbose): + +- `"OFF"` - Disable all logging +- `"ERROR"` - Only error messages +- `"WARN"` - Warnings and errors +- `"INFO"` - Informational messages, warnings, and errors +- `"DEBUG"` - Debug messages and above +- `"TRACE"` - All messages including trace-level details + +## Recommended Setup with Pytest + +/// warning | One-time Initialization +The FFI logging can only be initialized **once** per process. Attempting to configure it multiple times will result in an error. For this reason, it's recommended to set up logging in a session-scoped fixture. +/// + +The recommended way to configure FFI logging in your test suite is to use a pytest fixture with `autouse=True` and `scope="session"` in your `conftest.py` file: + +```python +import pytest +import pact_ffi + +@pytest.fixture(autouse=True, scope="session") +def pact_logging(): + """Configure Pact FFI logging for the test session.""" + pact_ffi.log_to_stderr("INFO") +``` + +This ensures that: + +1. Logging is configured automatically for all tests +2. It's only initialized once at the start of the test session +3. All test output includes relevant Pact FFI logs + +## Advanced Configuration + +For more advanced use cases, the `pact_ffi` module provides additional logging functions: + +### Logging to a File + +/// note | Not Yet Implemented +The `log_to_file` function is currently not implemented in the Python bindings. If you need this feature, please open an issue on the [Pact Python GitHub repository](https://github.com/pact-foundation/pact-python/issues). +/// + +To direct logs to a file instead of stderr, you would use: + +```python +import pact_ffi + +# This will be available in a future release +pact_ffi.log_to_file("/path/to/logfile.log", pact_ffi.LevelFilter.DEBUG) +``` + +### Logging to a Buffer + +For applications that need to capture and process logs programmatically, you can use [`log_to_buffer`][pact_ffi.log_to_buffer]: + +```python +import pact_ffi + +# Configure logging to an internal buffer +pact_ffi.log_to_buffer("DEBUG") +``` + +This is particularly useful for: + +- Capturing logs in CI/CD environments +- Including logs in test failure reports +- Processing or filtering log messages programmatically + +/// note | Retrieving Buffer Contents +The `fetch_log_buffer` function for retrieving buffered logs is currently not implemented in the Python bindings. If you need this feature, please open an issue on the [Pact Python GitHub repository](https://github.com/pact-foundation/pact-python/issues). +/// + +### Multiple Sinks + +/// note | Advanced Usage +The functions `logger_init`, `logger_attach_sink`, and `logger_apply` are currently not implemented in the Python bindings. If you need these features, please open an issue on the [Pact Python GitHub repository](https://github.com/pact-foundation/pact-python/issues). +/// + +For the most advanced scenarios, the FFI supports configuring multiple log sinks simultaneously (e.g., logging to both stderr and a file). This requires using the lower-level `logger_init`, `logger_attach_sink`, and `logger_apply` functions, which are planned for future implementation. + +## Troubleshooting + +### "Logger already initialized" Error + +If you see an error about the logger already being initialized, it means you're trying to configure FFI logging more than once. Ensure that: + +1. You're using a session-scoped fixture as shown above +2. You're not calling any of the `log_to_*` functions multiple times in your code +3. If running tests multiple times in the same process (e.g., with pytest-xdist), the fixture scope is set correctly + +### No Log Output + +If you're not seeing any log output: + +1. Check that the log level is appropriate - `"ERROR"` will only show errors, while `"INFO"` or `"DEBUG"` will show more information +2. Verify that the logging is configured before any Pact operations are performed +3. For `log_to_file`, ensure the file path is writable and the directory exists + +## Further Information + +For complete API documentation, see: + +- [`pact_ffi.log_to_stderr`][pact_ffi.log_to_stderr] +- [`pact_ffi.log_to_file`][pact_ffi.log_to_file] +- [`pact_ffi.log_to_buffer`][pact_ffi.log_to_buffer] +- [`pact_ffi.LevelFilter`][pact_ffi.LevelFilter] diff --git a/docs/provider.md b/docs/provider.md new file mode 100644 index 000000000..576f201df --- /dev/null +++ b/docs/provider.md @@ -0,0 +1,337 @@ +# Provider Testing + +Pact is a consumer-driven contract testing tool. The consumer specifies the expected interactions with the provider, which are used to create a contract. This contract is then used to verify that the provider behaves as expected. + +/// html | div[align="center"] + +```mermaid +sequenceDiagram + box Consumer Side + participant Consumer + participant P1 as Pact + end + box Provider Side + participant P2 as Pact + participant Provider + end + Consumer->>P1: GET /users/123 + P1->>Consumer: 200 OK + Consumer->>P1: GET /users/999 + P1->>Consumer: 404 Not Found + + P1--)P2: Pact Broker + + P2->>Provider: GET /users/123 + Provider->>P2: 200 OK + P2->>Provider: GET /users/999 + Provider->>P2: 404 Not Found +``` + +/// + +The provider verification process works by replaying the interactions from the consumer against the provider and checking that the responses match what was expected. This is done using the Pact files created by the consumer tests, either by reading them from the local file system or by fetching them from a Pact Broker. + +The core verification logic is implemented in Rust and exposed to Python through the [core Pact FFI](https://github.com/pact-foundation/pact-reference). This will help ensure feature parity between different language implementations and improve performance and reliability. This also brings compatibility with the latest Pact Specification (v4). + +## Verifying Pacts + +Pact Python's [`Verifier`][pact.verifier.Verifier] class provides the mechanism to fetch and verify Pacts against your provider application, while also facilitating provider state management and result publishing. + +### Basic Usage + +You can verify Pacts from a local directory as follows: + +```python +from pact import Verifier + +def test_provider(): + """Test the provider against the consumer contract.""" + verifier = ( + Verifier("my-provider") # Provider name + .add_source("./pacts/") # Directory containing Pact files + .add_transport(url="http://localhost:8080") # Provider URL + ) + + verifier.verify() +``` + +The `Verifier` inspects the specified directory for Pact files matching the provider name, and verifies each interaction against the running provider at the given URL. + +### Verifying from a Pact Broker + +Although local Pact files are useful for quick tests, in most cases you will want to verify Pacts from a Pact Broker. In this case, specify the broker URL and any necessary authentication: + +```python +from pact import Verifier + +def test_provider_from_broker(): + """Test the provider against contracts from a Pact Broker.""" + verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .broker_source( + "https://my-broker.example.com", + username="broker-username", # or use token="bearer-token" + password="broker-password", + ) + ) + + verifier.verify() +``` + +For advanced broker configurations, use the selector builder pattern to filter which Pacts to verify: + +```python +from pact import Verifier + +def test_provider_with_selectors(): + """Test with advanced broker selectors.""" + verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .broker_source( + "https://my-broker.example.com", + token="bearer-token", + selector=True, # Enable selector builder + ) + .include_pending() # Include pending pacts + .include_wip_since("2023-01-01") # Include WIP pacts since date + .provider_branch("main") + .consumer_version(branch="main") # Specific consumer version selectors + .consumer_version(branch="develop") # Can be called multiple times + .build() # Build the selector + ) + + verifier.verify() +``` + +#### Consumer Version Selectors + +The `consumer_version` method provides fine-grained control over which consumer pacts are verified. It can be called multiple times to add multiple selectors, which are combined with a logical OR (pacts matching any selector will be included). + +Common use cases: + +```python +# Verify pacts from a specific branch +.consumer_version(branch="feature/new-api") + +# Verify pacts from deployed or released versions +.consumer_version(deployed_or_released=True) + +# Verify pacts from main branch +.consumer_version(main_branch=True) + +# Verify pacts from a specific consumer only +.consumer_version(consumer="mobile-app", branch="main") + +# Verify pacts from a specific environment +.consumer_version(deployed=True, environment="production") +``` + +More information on the selector options is available in the [API reference][pact.verifier.BrokerSelectorBuilder]. + +## Logging + +To enable logging for debugging and troubleshooting, configure the FFI (Foreign Function Interface) logging using [`pact_ffi.log_to_stderr`][pact_ffi.log_to_stderr]. This is particularly useful when you need to understand what's happening during provider verification or diagnose issues with provider state handlers. + +The recommended approach is to set up logging in a pytest fixture within your `conftest.py`: + +```python +import pytest +import pact_ffi + +@pytest.fixture(autouse=True, scope="session") +def pact_logging(): + """Configure Pact FFI logging for the test session.""" + pact_ffi.log_to_stderr("INFO") +``` + +For more information on logging configuration, including advanced options and troubleshooting, see the [Logging Configuration](logging.md) page. + +### Publishing Results + +To publish verification results to the Broker: + +```python +verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .broker_source("https://my-broker.example.com", token="bearer-token") +) + +if "CI" in os.environ: + verifier.set_publish_options( # (1) + version="1.2.3", + branch="main", + ) + +verifier.verify() +``` + +1. While we use static values here, in practice, you would use dynamic values taken from your CI/CD environment, or helper function to extract version information from your source control system. + +## Configuration Options + +### Filtering + +Filter interactions to verify: + +```python +verifier = ( + Verifier("my-provider") + .filter(description="user.*", state="user exists") # Regex filters + .filter_consumers("mobile-app", "web-app") # Specific consumers only +) +``` + +### Custom Headers + +While the Pact contract should define all necessary request and response details, there are cases where you may need to add custom headers to every request made to the provider during verification (e.g., for authentication). + +```python +verifier = ( + Verifier("my-provider") + .add_custom_header("Authorization", "Bearer token123") + .add_custom_headers({ + "X-Debug-Mode": "true", + "X-Debug-Secret": "123-abc", + }) +) +``` + +## Provider States + +Provider states are a crucial concept in Pact testing. When a consumer creates a Pact, it specifies not just what request to make, but also what state the provider should be in when that request is made. This is expressed using the `.given(...)` method in consumer tests. + +For example, if a consumer test includes `given("user 123 exists")`, it means the provider must have a user with ID 123 in its system when the interaction is verified. A better approach is to parameterise the provider state instead of hard-coding values within the state name, such as `given("user exists", id=123, name="Alice")`. + +For these provider states to be meaningful, the provider tests need to set up the appropriate state before each interaction is verified. This is done using state handler methods. Optionally, these handlers can also perform teardown actions after the interaction is verified, which is useful for cleaning up test data. + +### State Handler Methods + +The `Verifier` class provides three ways to handle provider states: + +1. **Function-based handlers** - A single function handles all states +2. **Dictionary-based handlers** - Map state names to specific functions +3. **URL-based handlers** - External HTTP endpoint manages states + +> [!WARNING] +> +> If using mocking libraries, the function- and dictionary-based handlers must run in the same process as the provider application. For example, using `threading.Thread` to run the provider in a separate thread of the same process is acceptable, but using `multiprocessing.Process` to run the provider in a separate process will not work. + +### Function-Based State Handler + +A single function can handle all provider states: + +```python +from pact import Verifier +from typing import Literal, Any + +def handle_provider_state( + state: str, + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, +) -> None: + """Handle all provider state changes.""" + parameters = parameters or {} + if state == "user exists": + if action == "setup": + return create_user( + parameters.get("id", 123), + name=parameters.get("name", "Alice"), + ) + if action == "teardown": + return delete_user(parameters.get("id", 123)) + + if state == "no users exist": + if action == "setup": + return clear_all_users() + + msg = f"Unknown state/action: {state}/{action}" + raise ValueError(msg) + +verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .add_source("./pacts/") + .state_handler(handle_provider_state, teardown=True) +) + +verifier.verify() +``` + +### Dictionary-Based State Handler (Recommended) + +Map specific state names to dedicated handler functions: + +```python +from pact import Verifier +from typing import Literal, Any + +def mock_user_exists( + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, +) -> None: + """Mock the provider state where a user exists.""" + parameters = parameters or {} + user_id = parameters.get("id", 123) + + if action == "setup": + # Set up the user in your test database/mock + return UserDb.create(User( + id=user_id, + name=parameters.get("name", "Test User"), + email=parameters.get("email", "test@example.com"), + )) + if action == "teardown": + # Clean up after the test + return UserDb.delete(user_id) + +def mock_user_does_not_exist( + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, +) -> None: + """Mock the provider state where a user does not exist.""" + parameters = parameters or {} + user_id = parameters.get("id", 123) + + if action == "setup" and user_id: + # Ensure the user doesn't exist + if UserDb.get(user_id): + UserDb.delete(user_id) + +# Map state names to handler functions +state_handlers = { + "user exists": mock_user_exists, + "user 123 exists": mock_user_exists, + "user does not exist": mock_user_does_not_exist, +} + +verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .add_source("./pacts/") + .state_handler(state_handlers, teardown=True) +) + +verifier.verify() +``` + +### URL-Based State Handler + +This approach relies on the provider exposing an HTTP endpoint to manage provider states. This can be necessary if the handler logic cannot be implemented in Python (for example, if the provider is written in a different language). + +```python +verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .add_source("./pacts/") + .state_handler( + "http://localhost:8080/_pact/setup", # Your state setup endpoint + teardown=True, + body=True, # Send state info in request body + ) +) +``` + +The state setup endpoint should handle POST requests with the state information if `body=True` is set; otherwise, the state information will be passed through query parameters and headers. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 000000000..0160be4bf --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,79 @@ +# Releases + +Pact Python is made available through both GitHub releases and PyPI. The GitHub releases also come with a summary of changes and contributions since the last release. + +The entire process is automated through three GitHub Actions workflows (one per package), each running a three-stage pipeline. A description of the process is provided [below](#release-pipeline). + +## Packages + +Pact Python is split into three independently versioned packages: + +- **[`pact-python`](https://pypi.org/p/pact-python)**: the core library. Versioned with [semantic versioning](https://semver.org/), derived from conventional commits via [git-cliff](https://git-cliff.org/). +- **[`pact-python-ffi`](https://pypi.org/p/pact-python-ffi)**: Python bindings for the [pact-reference](https://github.com/pact-foundation/pact-reference) Rust library. Versioned as `{upstream}.{N}` (e.g. `0.4.28.0`), tracking upstream `libpact_ffi` releases. +- **[`pact-python-cli`](https://pypi.org/p/pact-python-cli)**: Python wrapper for the [pact-standalone](https://github.com/pact-foundation/pact-standalone) CLI tools. Versioned as `{upstream}.{N}` (e.g. `2.4.0.0`), tracking upstream releases. + +## Versioning + +### pact-python (core) + +The core package follows [semantic versioning](https://semver.org/). Breaking changes are indicated by a major version bump, new features by a minor version bump, and bug fixes by a patch version bump. + +There are a couple of exceptions to the [semantic versioning](https://semver.org/) rules: + +- Dropping support for a Python version is not considered a breaking change and is not necessarily accompanied by a major version bump. +- Private APIs are not considered part of the public API and are not subject to the same rules as the public API. They can be changed at any time without a major version bump. Private APIs are denoted by a leading underscore in the name. +- Deprecations are not considered breaking changes and are not necessarily accompanied by a major version bump. Their removal is considered a breaking change and is accompanied by a major version bump. +- Changes to the type annotations will not be considered breaking changes, unless they are accompanied by a change to the runtime behaviour. + +Any deviation from the standard semantic versioning rules will be clearly documented in the release notes. + +The next version is computed automatically by [git-cliff](https://git-cliff.org/) from the [conventional commit](https://www.conventionalcommits.org/) history since the last release tag. + +### pact-python-ffi and pact-python-cli + +These packages follow their upstream projects' versioning, extended with a fourth component `N` that starts at `0` for each new upstream version and increments with each packaging-only release. When a new upstream version is released, `N` resets to `0`. + +### Version storage + +Each package stores its version as a static string in its `pyproject.toml`. The version is updated automatically by the release pipeline during the prepare stage and committed to the release branch. + +## Release Pipeline + +Each package has its own release workflow (`.github/workflows/release-{core,ffi,cli}.yml`). All three follow the same three-stage process: + +### Stage 1: Prepare + +Triggered by every push to `main`. + +The `prepare` job runs `scripts/release.py prepare `, which: + +1. Computes the next version (via git-cliff for core; by fetching the latest upstream release for ffi/cli). +2. Updates `pyproject.toml` with the new version. +3. Prepends the new release entry to `CHANGELOG.md` using git-cliff, preserving all previous entries (including any manual edits). +4. Force-pushes those changes to a fixed release branch (e.g. `release/pact-python`). +5. Creates the release PR if it does not exist, or updates its title and body if it does. + +The release PR gives maintainers an opportunity to review and manually adjust the changelog before it is published. + +A PAT (`GH_TOKEN`) is used to create/update the release PR so that the PR triggers the expected downstream workflow runs (GitHub's loop-prevention rules block `GITHUB_TOKEN`-triggered events from starting new workflow runs). + +### Stage 2: Tag + +Triggered when the release PR is merged (GitHub fires a `pull_request` event with `action: closed` and `merged: true`). + +The `tag` job runs `scripts/release.py tag `, which reads the version from `pyproject.toml` on `main` and pushes a git tag of the form `/` (e.g. `pact-python/3.2.2`). + +A PAT (`GH_TOKEN`) is used instead of the default `GITHUB_TOKEN` so that the tag push is able to trigger the downstream Stage 3 workflow run (GitHub's loop-prevention rules block `GITHUB_TOKEN`-triggered events from starting new workflow runs). + +### Stage 3: Publish + +Triggered by a tag push matching the package's tag prefix. + +The `publish` job: + +1. Extracts the changelog entry for the tagged version directly from the committed `CHANGELOG.md` (preserving any manual edits made to the release PR). +2. Builds the source distribution and wheels across all supported platforms and architectures. +3. Creates a GitHub release with the extracted changelog. +4. Publishes all artifacts to PyPI. + +The `publish` job uses the `pypi` GitHub environment, which can be configured to require manual approval before publishing. diff --git a/docs/scripts/.ruff.toml b/docs/scripts/.ruff.toml new file mode 100644 index 000000000..ee6a3f3bc --- /dev/null +++ b/docs/scripts/.ruff.toml @@ -0,0 +1,8 @@ +#:schema https://www.schemastore.org/ruff.json + +extend = "../../pyproject.toml" + +[lint] +ignore = [ + "INP001", # Forbid implicit namespaces +] diff --git a/docs/scripts/markdown.py b/docs/scripts/markdown.py new file mode 100644 index 000000000..a8af79d03 --- /dev/null +++ b/docs/scripts/markdown.py @@ -0,0 +1,147 @@ +""" +Script to merge Markdown documentation from the main codebase into the docs. + +This script is run by mkdocs-gen-files when the documentation is built and +imports Markdown documentation from the main codebase so that it can be included +in the documentation site. For example, a Markdown file located at +`some/path/foo.md` will be treated as if it was located at +`docs/some/path/foo.md` without the need for symlinks or copying the file. + +If the destination file already exists (either because it is a real file, or was +otherwise already generated), the script will raise a RuntimeError. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import TYPE_CHECKING, TypeVar + +import mkdocs_gen_files +from mkdocs_gen_files.editor import FilesEditor +from pathspec import PathSpec + +if TYPE_CHECKING: + from collections.abc import Sequence + +EDITOR = FilesEditor.current() +_T = TypeVar("_T") + + +def is_subsequence(a: Sequence[_T], b: Sequence[_T]) -> int | None: + """ + Checks if a is a sublist of b. + + This will return the index of the first element of a in b if a is a sublist + of b, or None otherwise. + """ + if len(a) > len(b): + return None + for i in range(len(b) - len(a) + 1): + if all(a[j] == b[i + j] for j in range(len(a))): + return i + return None + + +def process_markdown( + src: str, + ignore: list[str] | None = None, + mapping: list[tuple[str, str]] | None = None, +) -> None: + """ + Process out-of-docs Markdown files. + + The source directory is relative to the root of the repository, and only + Markdown files which are version controlled are processed. Once processed, + they will be available to MkDocs as if they were located in the `docs` + directory. + + Args: + src: + The source directory to process. + + ignore: + A list of patterns to ignore. This uses the same syntax as `.gitignore`. + + mapping: + List of tuples containing the source and destination paths to map. + Note that the list is processed in order, with later mappings + applied after earlier mappings. + """ + ignore_spec = PathSpec.from_lines("gitwildmatch", ignore or []) + mapping_parts: list[tuple[Sequence[str], Sequence[str]]] = [ + (Path(a).parts, Path(b).parts) for a, b in mapping or [] + ] + files = sorted( + Path(p) + for p in subprocess # noqa: S603 + .check_output( + ["git", "ls-files", src], # noqa: S607 + ) + .decode("utf-8") + .splitlines() + if p.endswith(".md") and not ignore_spec.match_file(p) + ) + + for file in files: + file_parts: list[str] = list(file.parts) + for from_parts, to_parts in mapping_parts: + idx = is_subsequence(from_parts, file_parts) + if idx is not None: + file_parts = [ + *file_parts[:idx], + *to_parts, + *file_parts[idx + len(from_parts) :], + ] + destination = Path(*file_parts) + + if str(destination) in EDITOR.files: + print( # noqa: T201 + f"Unable to copy {file} to {destination} because the file already" + " exists at the destination.", + file=sys.stderr, + ) + msg = f"File {destination} already exists." + raise RuntimeError(msg) + + with ( + Path(file).open("r", encoding="utf-8") as fi, + mkdocs_gen_files.open( + destination, + "w", + encoding="utf-8", + ) as fd, + ): + fd.write(fi.read()) + + mkdocs_gen_files.set_edit_path( + destination, + f"https://github.com/pact-foundation/pact-python/edit/main/{file}", + ) + + +if __name__ == "": + process_markdown( + ".", + ignore=[ + "docs", + "pact-python-cli", + "pact-python-ffi", + ".github", + ], + ) + process_markdown( + "pact-python-ffi", + mapping=[ + ("pact-python-ffi/docs", "pact-python-ffi"), + ("pact-python-ffi", "pact-python-ffi"), + ], + ) + process_markdown( + "pact-python-cli", + mapping=[ + ("pact-python-cli/docs", "pact-python-cli"), + ("pact-python-cli", "pact-python-cli"), + ], + ) diff --git a/docs/scripts/other.py b/docs/scripts/other.py new file mode 100644 index 000000000..31d60dbf2 --- /dev/null +++ b/docs/scripts/other.py @@ -0,0 +1,126 @@ +""" +Create placeholder files for all other files in the codebase. + +This script is run by mkdocs-gen-files when the documentation is built and +creates placeholder files for all other files in the codebase. This is done so +that the documentation site can link to all files in the codebase, even if they +aren't part of the documentation proper. + +If the files are binary, they are copied as-is (e.g. for images), otherwise a +HTML redirect is created. + +If the destination file already exists (either because it is a real file, or was +otherwise already generated), the script will ignore the current file and +continue silently. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING + +import mkdocs_gen_files +from mkdocs_gen_files.editor import FilesEditor + +if TYPE_CHECKING: + import io + +EDITOR = FilesEditor.current() + +# These paths are relative to the project root, *not* the current file. +SRC_ROOT = "." +DOCS_DEST = "." + +# List of all files version controlled files in the SRC_ROOT +ALL_FILES = sorted( + map( + Path, + subprocess # noqa: S603 + .check_output(["git", "ls-files", SRC_ROOT]) # noqa: S607 + .decode("utf-8") + .splitlines(), + ), +) + + +def is_binary(buffer: bytes) -> bool: + """ + Determine whether the given buffer is binary or not. + + The check is done by attempting to decode the buffer as UTF-8. If this + succeeds, the buffer is not binary. If it fails, the buffer is binary. + + The entire buffer will be checked, therefore if checking whether a file is + binary, only the start of the file should be passed. + + Args: + buffer: + The buffer to check. + + Returns: + True if the buffer is binary, False otherwise. + """ + try: + buffer.decode("utf-8") + except UnicodeDecodeError: + return True + else: + return False + + +for source_path in ALL_FILES: + if not source_path.is_file(): + continue + if source_path.parts[0] == "docs": + continue + + dest_path = Path(DOCS_DEST, source_path) + + if str(dest_path) in EDITOR.files: + continue + + fi: io.IOBase + with Path(source_path).open("rb") as fi: + buf = fi.read(2048) + + if is_binary(buf): + if source_path.stat().st_size < 16 * 2**20: + # Copy the file only if it's less than 16MB. + with ( + Path(source_path).open("rb") as fi, + mkdocs_gen_files.open( + dest_path, + "wb", + ) as fd, + ): + fd.write(fi.read()) + else: + # File is too big, create a redirect. + url = ( + "https://github.com" + "/pact-foundation/pact-python" + "/raw" + "/develop" + f"/{source_path}" + ) + with mkdocs_gen_files.open(dest_path, "w", encoding="utf-8") as fd: + fd.write(f'') + fd.write(f"# Redirecting to {url}...") + fd.write(f"[Click here if you are not redirected]({url})") + + mkdocs_gen_files.set_edit_path( + dest_path, + f"https://github.com/pact-foundation/pact-python/edit/develop/{source_path}", + ) + + else: + with ( + Path(source_path).open("r", encoding="utf-8") as fi, + mkdocs_gen_files.open( + dest_path, + "w", + encoding="utf-8", + ) as fd, + ): + fd.write(fi.read()) diff --git a/docs/scripts/python.py b/docs/scripts/python.py new file mode 100644 index 000000000..16954964d --- /dev/null +++ b/docs/scripts/python.py @@ -0,0 +1,233 @@ +""" +Script used by mkdocs-gen-files to generate documentation from Python. + +The script is run by mkdocs-gen-files when the documentation is built in order +to generate documentation from Python docstrings. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING, TypeVar + +import mkdocs_gen_files +from pathspec import PathSpec + +if TYPE_CHECKING: + from collections.abc import Sequence + +_T = TypeVar("_T") + + +def is_subsequence(a: Sequence[_T], b: Sequence[_T]) -> int | None: + """ + Checks if a is a sublist of b. + + This will return the index of the first element of a in b if a is a sublist + of b, or None otherwise. + """ + if len(a) > len(b): + return None + for i in range(len(b) - len(a) + 1): + if all(a[j] == b[i + j] for j in range(len(a))): + return i + return None + + +def map_destination( + path: Path, + mapping: list[tuple[Sequence[str], Sequence[str]]], +) -> Path | None: + """ + Takes a path to a Python files and maps it to a destination Markdown file. + + A few notes about some special files: + + - `__main__.py` files are ignored. + - `__init__.py` files are mapped to the directory containing the file, with + the name `README.md`. + + Args: + path: + The path to the Python file. + + mapping: + List of tuples containing the source and destination paths to map. + Note that the list is processed in order, with later mappings + applied after earlier mappings. + """ + segments = list(path.with_suffix(".md").parts) + + if segments[-1] == "__main__.md": + return None + + if segments[-1] == "__init__.md": + segments[-1] = "README.md" + + for from_parts, to_parts in mapping: + idx = is_subsequence(from_parts, segments) + if idx is not None: + segments = [ + *segments[:idx], + *to_parts, + *segments[idx + len(from_parts) :], + ] + return Path(*segments) + + +def map_python_identifier( + path: Path, + mapping: list[tuple[str, str]], +) -> str | None: + """ + Takes a path to a Python files and maps it to a destination Markdown file. + + A few notes about some special files: + + - `__main__.py` files are ignored. + - `__init__.py` files are handled as usual within Python, i.e., + `some/path/__init__.py` is identified as `some.path`, and therefore is + equivalent to `some/path.py`. + + Args: + path: + The path to the Python file. + + mapping: + List of tuples containing the source and destination Python + identifiers to map. Note that the list is processed in order, with + later mappings applied after earlier mappings. + """ + segments = list(path.with_suffix("").parts) + + if segments[-1] == "__main__": + return None + + if segments[-1] == "__init__": + segments = segments[:-1] + + python_identifier = ".".join(segments) + for from_identifier, to_identifier in mapping: + idx = is_subsequence(from_identifier.split("."), python_identifier.split(".")) + if idx is not None: + python_identifier = ( + python_identifier[:idx] + + to_identifier + + python_identifier[idx + len(from_identifier) :] + ) + return python_identifier + + +def process_python( + src: str, + ignore: list[str] | None = None, + destination_mapping: list[tuple[str, str]] | None = None, + python_mapping: list[tuple[str, str]] | None = None, + *, + ignore_private: bool = True, +) -> None: + """ + Process the Python files in the given directory. + + The source directory is relative to the root of the repository, and only + Python files which are version controlled are processed. Once processed, + they will be available to MkDocs as if they were located in the `docs` + directory. + + This makes use of `mkdocstrings` to generate documentation from the Python + docstrings. + + Args: + src: + The source directory to process. + + ignore: + A list of patterns to ignore. This uses the same syntax as + `.gitignore`. + + destination_mapping: + List of tuples containing the source and destination paths to map. + Note that the list is processed in order, with later mappings + applied after earlier mappings. + + python_mapping: + List of tuples containing the source and destination Python + identifiers to map. Note that the list is processed in order, with + later mappings applied after earlier mappings. This is applied + independently of the `destination_mapping` argument. + + ignore_private: + Whether to ignore private modules (those starting with an underscore + `_`, with the exception of special file names such as `__init__.py` + and `__main__.py`). + """ + ignore_spec = PathSpec.from_lines("gitwildmatch", ignore or []) + files = sorted( + Path(p) + for p in subprocess # noqa: S603 + .check_output( + ["git", "ls-files", src], # noqa: S607 + ) + .decode("utf-8") + .splitlines() + if p.endswith(".py") and not ignore_spec.match_file(p) + ) + + for file in files: + if ( + ignore_private + and file.name.startswith("_") + and file.stem not in ["__init__", "__main__"] + ): + continue + + destination = map_destination( + file, + [(Path(a).parts, Path(b).parts) for a, b in destination_mapping or []], + ) + python_identifier = map_python_identifier(file, python_mapping or []) + + if not destination or not python_identifier: + continue + + with mkdocs_gen_files.open( + destination, + "a" if destination.exists() else "w", + encoding="utf-8", + ) as fd: + print("::: " + python_identifier, file=fd) + + mkdocs_gen_files.set_edit_path( + destination, + f"https://github.com/pact-foundation/pact-python/edit/main/{file}", + ) + + +if __name__ == "": + process_python( + "src/pact", + destination_mapping=[ + ("src/pact", "api"), + ], + python_mapping=[("src.pact", "pact")], + ignore=["src/pact/v2"], + ) + process_python( + "pact-python-cli/src/pact_cli", + python_mapping=[("pact-python-cli.src.pact_cli", "pact_cli")], + destination_mapping=[ + ("pact-python-cli/src/pact_cli", "pact-python-cli/api"), + ], + ) + process_python( + "pact-python-ffi/src/pact_ffi", + python_mapping=[("pact-python-ffi.src.pact_ffi", "pact_ffi")], + destination_mapping=[ + ("pact-python-ffi/src/pact_ffi", "pact-python-ffi/api"), + ], + ) + process_python( + "examples", + ignore=["examples/v2"], + ) diff --git a/docs/scripts/rewrite-docs-links.py b/docs/scripts/rewrite-docs-links.py new file mode 100644 index 000000000..d0364aa89 --- /dev/null +++ b/docs/scripts/rewrite-docs-links.py @@ -0,0 +1,58 @@ +""" +Rewrite links to docs. + +This hook is used to rewrite links within the documentation. Due to the way +Markdown files are collected across the repository (specifically, within `docs/` +and outside of `docs/`), links that cross this boundary don't already work +correctly. + +This hook is used to rewrite links dynamically. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +import mkdocs.plugins + +if TYPE_CHECKING: + from mkdocs.config.defaults import MkDocsConfig + from mkdocs.structure.files import Files + from mkdocs.structure.pages import Page + + +@mkdocs.plugins.event_priority(-50) +def on_page_markdown( + markdown: str, + page: Page, # noqa: ARG001 + config: MkDocsConfig, # noqa: ARG001 + files: Files, # noqa: ARG001 +) -> str | None: + """ + Rewrite links to docs. + + Performs a simple regex substitution on the Markdown content. Specifically, + any link to a file within `docs/{path}` is rewritten to just `/{path}`, and + any links containing `../docs/..` are rewritten to just `../..`. + + This is clearly fragile, but until a better solution is needed, this should + be sufficient. + """ + # Find all links that start with `docs/` and rewrite them. + markdown = re.sub( + r"\]\((?Pdocs/[^)]+)\)", + lambda match: f"]({match.group('link')[5:]})", + markdown, + count=0, + flags=re.MULTILINE, + ) + + # Find links that have an embedded `/docs/` and rewrite them. + return re.sub( + r"\]\((?P[^)]+/docs/[^)]+)\)", + lambda match: f"]({match.group('link').replace('/docs/', '/')})", + markdown, + count=0, + flags=re.MULTILINE, + ) diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 000000000..8e817a5b7 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,2 @@ +**/pacts/* +**/uv.lock diff --git a/examples/README.md b/examples/README.md index 7c1bce7cf..2af1fdc04 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,220 +1,193 @@ # Examples -## Table of Contents +This directory contains examples demonstrating how to use Pact in Python for various testing scenarios. While this document and the documentation within the examples themselves are intended to be mostly self-contained, it is highly recommended that you read the [Pact Documentation](https://docs.pact.io/) as well. - * [Overview](#overview) - * [broker](#broker) - * [common](#common) - * [consumer](#consumer) - * [flask_provider](#flask_provider) - * [fastapi_provider](#fastapi_provider) - * [message](#message) - * [pacts](#pacts) +Each example is self-contained with its own dependency management using a `pyproject.toml` file. This allows you to run examples independently without affecting your global Python environment or other examples. -## Overview +The code within the examples is intended to be well-documented and you are encouraged to look through the code as well (or submit a PR if anything is unclear!). -Here you can find examples of how to use Pact using the python language. You can find more of an overview on Pact in the -[Pact Introduction]. +## Available Examples -Examples are given of both the [Consumer] and [Provider], this does not mean however that you must use python for both. -Different languages can be mixed and matched as required. +### Patterns Catalog -In these examples, `1` is just used to meet the need of having *some* [Consumer] or [Provider] version. In reality, you -will generally want to use something more complicated and automated. Guidelines and best practices are available in the -[Versioning in the Pact Broker] +#### [Pact Patterns Catalog](./catalog/README.md) -## broker +- **Location**: `examples/catalog/` +- **Purpose**: Focused code snippets demonstrating specific Pact patterns +- **Content**: Well-documented use cases and techniques without full application context -The [Pact Broker] stores [Pact file]s and [Pact verification] results. It is used here for the [consumer](#consumer), -[flask_provider](#flask-provider) and [message](#message) tests. +### HTTP Examples -### Running +#### [aiohttp and Flask](./http/aiohttp_and_flask/README.md) -These examples run the [Pact Broker] as part of the tests when specified. It can be run outside the tests as well by -performing the following command from a separate terminal in the `examples/broker` folder: -```bash -docker-compose up -``` +- **Location**: `examples/http/aiohttp_and_flask/` +- **Consumer**: aiohttp-based HTTP client +- **Provider**: Flask-based HTTP server -You should then be able to open a browser and navigate to http://localhost where you will initially be able to see the -default Example App/Example API Pact. +#### [requests and FastAPI](./http/requests_and_fastapi/README.md) -Running the [Pact Broker] outside the tests will mean you are able to then see the [Pact file]s submitted to the -[Pact Broker] as the various tests are performed. +- **Location**: `examples/http/requests_and_fastapi/` +- **Consumer**: requests-based HTTP client +- **Provider**: FastAPI-based HTTP server -## common +#### [Service as Consumer and Provider](./http/service_consumer_provider/README.md) -To avoid needing to duplicate certain fixtures, such as starting up a docker based Pact broker (to demonstrate how the -test process could work), the shared fixtures used by the pytests have all been placed into a single location.] -This means it is easier to see the relevant code for the example without having to go through the boilerplate fixtures. -See [Requiring/Loading plugins in a test module or conftest file] for further details of this approach. +- **Location**: `examples/http/service_consumer_provider/` +- **Scenario**: A single service (`user-service`) acting as both: + - **Provider** to a frontend client + - **Consumer** of an upstream auth service -## consumer +### Message Examples -Pact is consumer-driven, which means first the contracts are created. These Pact contracts are generated during -execution of the consumer tests. +- **Location**: `examples/message/` +- **Status**: 🚧 To be updated -### Running +### Plugin Examples -When the tests are run, the "minimum" is to generate the Pact contract JSON, additional options are available. The -following commands can be run from the `examples/consumer` folder: +- **Location**: `examples/plugins/` +- **Status**: 🚧 To be updated -- Install any necessary dependencies: - ```bash - pip install -r requirements.txt - ``` -- To startup the broker, run the tests, and publish the results to the broker: - ```bash - pytest --run-broker True --publish-pact 1 - ``` -- Alternatively the same can be performed with the following command, which is called from a `make consumer`: - ```bash - ./run_pytest.sh - ``` -- To run the tests, and publish the results to the broker which is already running: - ```bash - pytest --publish-pact 1 - ``` -- To just run the tests: - ```bash - pytest - ``` +## Running Examples -### Output +Each example can be run independently. Navigate to the specific example directory and use your preferred dependency manager. -The following file(s) will be created when the tests are run: +## Overview -| Filename | Contents | -|---------------------------------------------| ----------| -| consumer/pact-mock-service.log | All interactions with the mock provider such as expected interactions, requests, and interaction verifications. | -| consumer/userserviceclient-userservice.json | This contains the Pact interactions between the `UserServiceClient` and `UserService`, as defined in the tests. The naming being derived from the named Pacticipants: `Consumer("UserServiceClient")` and `Provider("UserService")` | +Pact is a contract testing tool. Contract testing is a way to ensure that services (such as an API provider and a client) can communicate with each other. An interaction between a _consumer_ (i.e., a HTTP client, mobile app, website, microservice, etc.) and a _provider_ (i.e., a web server, microservice, etc.) would typically look like this: -## flask_provider +/// html | div[align="center"] -The Flask [Provider] example consists of a basic Flask app, with a single endpoint route. -This implements the service expected by the [consumer](#consumer). +```mermaid +sequenceDiagram + participant Consumer + participant Provider + Consumer ->> Provider: GET /users/123 + Provider ->> Consumer: 200 OK + Consumer ->> Provider: GET /users/999 + Provider ->> Consumer: 404 Not Found +``` -Functionally, this provides the same service and tests as the [fastapi_provider](#fastapi_provider). Both are included to -demonstrate how Pact can be used in different environments with different technology stacks and approaches. +Pact allows for each side of the interaction to be tested independently. Pact achieves this by mocking the other side of the interaction: + +/// html | div[align="center"] + +```mermaid +sequenceDiagram + box Consumer Side + participant Consumer + participant P1 as Pact + end + box Provider Side + participant P2 as Pact + participant Provider + end + Consumer->>P1: GET /users/123 + P1->>Consumer: 200 OK + Consumer->>P1: GET /users/999 + P1->>Consumer: 404 Not Found + + P1--)P2: Pact Broker + + P2->>Provider: GET /users/123 + Provider->>P2: 200 OK + P2->>Provider: GET /users/999 + Provider->>P2: 404 Not Found +``` -The [Provider] side is responsible for performing the tests to verify if it is compliant with the [Pact file] contracts -associated with it. +Pact is **consumer driven**. This means that the consumer is responsible for defining the interactions it expects from the provider through the pattern of -As such, the tests use the pact-python Verifier to perform this verification. Two approaches are demonstrated: -- Testing against the [Pact broker]. Generally this is the preferred approach, see information on [Sharing Pacts]. -- Testing against the [Pact file] directly. If no [Pact broker] is available you can verify against a static [Pact file]. + +> Given {provider state}
+> Upon receiving {description}
+> With {request}
+> Will respond with {response}
+ -### Running +When the consumer tests are executed, a Pact mock server is set up that will respond to the requests as defined by the consumer. When the consumer tests are merged into the main branch, the Pact contract is sent to the Pact Broker. -To avoid package version conflicts with different applications, it is recommended to run these tests from a -[Virtual Environment] +When the provider tests are executed, all contracts are retrieved from the Pact Broker. The provider then sets up a mock client that will make the requests as defined by the consumer. Pact then verifies that the responses from the provider match the expected responses defined by the consumer. -The following commands can be run from within your [Virtual Environment], in the `examples/flask_provider`. +In this way, Pact is consumer-driven and can ensure that the provider is compatible with the consumer. While the examples showcase both sides in Python, this is absolutely not required. The provider could be written in any language, and satisfy contracts from a number of consumers all written in different languages. -To perform the python tests: -```bash -pip install -r requirements.txt # Install the dependencies for the Flask example -pip install -e ../../ # Using setup.py in the pact-python root, install any pact dependencies and pact-python -./run_pytest.sh # Wrapper script to first run Flask, and then run the tests -``` +## Consumer -To perform verification using CLI to verify the [Pact file] against the Flask [Provider] instead of the python tests: -```bash -pip install -r requirements.txt # Install the dependencies for the Flask example -./verify_pact.sh # Wrapper script to first run Flask, and then use `pact-verifier` to verify locally -``` +Consumer tests define the contract by specifying the interactions the consumer expects from the provider. These tests focus on the consumer's perspective and needs. -To perform verification using CLI, but verifying the [Pact file] previously provided by a [Consumer], and publish the -results. This example requires that the [Pact broker] is already running, and the [Consumer] tests have been published -already, described in the [consumer](#consumer) section above. -```bash -pip install -r requirements.txt # Install the dependencies for the Flask example -./verify_pact.sh 1 # Wrapper script to first run Flask, and then use `pact-verifier` to verify and publish -``` +### Principles -These examples demonstrate by first launching Flask via a `python -m flask run`, you may prefer to start Flask using an -`app.run()` call in the python code instead, see [How to Run a Flask Application]. Additionally for tests, you may want -to manage starting and stopping Flask as part of a fixture setup. Any approach can be chosen here, in line with your -existing Flask testing practices. +- **Core interactions are defined**: Only test the interactions your consumer actually uses +- **Minimal requests and responses**: Define only the required headers, query parameters, and body fields that your consumer needs +- **Consumer-driven**: The consumer decides what it needs from the provider, not what the provider offers +- **Independent testing**: Consumer tests run against a Pact mock provider, not the real provider -### Output +### Best Practices -The following file(s) will be created when the tests are run +- If your consumer doesn't use a field from the provider's response, it should be safe for the provider to remove that field +- Use Pact matchers to make contracts flexible (e.g., `match.integer` instead of hardcoded values) +- Test error scenarios your consumer needs to handle (4xx, 5xx responses) +- Keep contracts focused on business logic, not implementation details -| Filename | Contents | -|-----------------------------| ----------| -| flask_provider/log/pact.log | All Pact interactions with the Flask Provider. Every interaction example retrieved from the Pact Broker will be performed during the Verification test; the request/response logged here. | +### Contract Publishing -## fastapi_provider +When consumer tests pass, the generated Pact contracts should be published to a Pact Broker. This makes them available for provider verification and enables the consumer-driven workflow. -The FastAPI [Provider] example consists of a basic FastAPI app, with a single endpoint route. -This implements the service expected by the [consumer](#consumer). +## Provider -Functionally, this provides the same service and tests as the [flask_provider](#flask_provider). Both are included to -demonstrate how Pact can be used in different environments with different technology stacks and approaches. +Provider tests verify that the actual provider implementation satisfies all contracts defined by its consumers. These tests ensure the provider can fulfill its obligations in the consumer-provider relationship. -The [Provider] side is responsible for performing the tests to verify if it is compliant with the [Pact file] contracts -associated with it. +### Core Principles -As such, the tests use the pact-python Verifier to perform this verification. Two approaches are demonstrated: -- Testing against the [Pact broker]. Generally this is the preferred approach, see information on [Sharing Pacts]. -- Testing against the [Pact file] directly. If no [Pact broker] is available you can verify against a static [Pact file]. -- -### Running +- **Verify all consumer contracts**: The provider must satisfy every interaction defined by all its consumers +- **Provider state management**: Set up the correct application state before each interaction is verified +- **Real provider testing**: Verification runs against the actual provider implementation, not mocks +- **Fail fast**: Any contract violation should cause provider tests to fail immediately -To avoid package version conflicts with different applications, it is recommended to run these tests from a -[Virtual Environment] +### Provider States -The following commands can be run from within your [Virtual Environment], in the `examples/fastapi_provider`. +Provider states are a key concept for ensuring your provider is in the correct state before an interaction: -To perform the python tests: -```bash -pip install -r requirements.txt # Install the dependencies for the FastAPI example -pip install -e ../../ # Using setup.py in the pact-python root, install any pact dependencies and pact-python -./run_pytest.sh # Wrapper script to first run FastAPI, and then run the tests -``` +- **Setup**: Use provider state callbacks to configure your application (e.g., create test data) +- **Isolation**: Each interaction should have a clean, predictable state +- **Mocking**: Mock external dependencies (databases, APIs) rather than requiring real infrastructure -To perform verification using CLI to verify the [Pact file] against the FastAPI [Provider] instead of the python tests: -```bash -pip install -r requirements.txt # Install the dependencies for the FastAPI example -./verify_pact.sh # Wrapper script to first run FastAPI, and then use `pact-verifier` to verify locally -``` +### Contract Retrieval -To perform verification using CLI, but verifying the [Pact file] previously provided by a [Consumer], and publish the -results. This example requires that the [Pact broker] is already running, and the [Consumer] tests have been published -already, described in the [consumer](#consumer) section above. -```bash -pip install -r requirements.txt # Install the dependencies for the FastAPI example -./verify_pact.sh 1 # Wrapper script to first run FastAPI, and then use `pact-verifier` to verify and publish -``` +Provider tests retrieve contracts from the Pact Broker and verify them against the running provider: + +- Use selectors to choose which contracts to verify (e.g., latest, main branch, specific versions) +- Configure which consumer versions to verify based on your deployment strategy +- Publish verification results back to the broker + +## Broker + +The Pact Broker acts as the central contract repository and coordination point between consumers and providers. It stores contracts, verification results, and provides tools for managing the contract testing workflow. + +### How It Works -### Output +- **Contract storage**: Consumers publish their contracts to the broker after successful test runs +- **Contract retrieval**: Providers fetch relevant contracts from the broker for verification +- **Verification results**: Providers publish verification results back to the broker +- **Visibility**: Teams can see which contracts exist, their verification status, and compatibility matrix -The following file(s) will be created when the tests are run +### Publishing Contracts -| Filename | Contents | -|-------------------------------| ----------| -| fastapi_provider/log/pact.log | All Pact interactions with the FastAPI Provider. Every interaction example retrieved from the Pact Broker will be performed during the Verification test; the request/response logged here. | +Consumers should publish contracts when: +- Consumer tests pass successfully +- Changes are merged to main branch +- Deploying to production environments -## message +### Retrieving Contracts -TODO +Providers can use selectors to determine which contracts to verify: -## pacts +- **Latest**: Most recent contract from each consumer +- **Branch-based**: Contracts from specific git branches (e.g., `main`, `develop`) +- **Environment-based**: Contracts from consumers deployed to specific environments +- **Tag-based**: Contracts tagged with specific labels -Both the Flask and the FastAPI [Provider] examples implement the same service the [Consumer] example interacts with. -This folder contains the generated [Pact file] for reference, which is also used when running the [Provider] tests -without a [Pact Broker]. +### Versioning Strategy -[Pact Broker]: https://docs.pact.io/pact_broker -[Pact Introduction]: https://docs.pact.io/ -[Consumer]: https://docs.pact.io/getting_started/terminology#service-consumer -[Provider]: https://docs.pact.io/getting_started/terminology#service-provider -[Versioning in the Pact Broker]: https://docs.pact.io/getting_started/versioning_in_the_pact_broker/ -[Pact file]: https://docs.pact.io/getting_started/terminology#pact-file -[Pact verification]: https://docs.pact.io/getting_started/terminology#pact-verification] -[Virtual Environment]: https://docs.python.org/3/tutorial/venv.html -[Sharing Pacts]: https://docs.pact.io/getting_started/sharing_pacts/] -[How to Run a Flask Application]: https://www.twilio.com/blog/how-run-flask-application -[Requiring/Loading plugins in a test module or conftest file]: https://docs.pytest.org/en/6.2.x/writing_plugins.html#requiring-loading-plugins-in-a-test-module-or-conftest-file \ No newline at end of file +- Use semantic versioning or commit SHA for contract versions +- Tag contracts when deploying to different environments +- Use "can-i-deploy" checks before releasing to ensure compatibility diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/examples/broker/docker-compose.yml b/examples/broker/docker-compose.yml deleted file mode 100644 index b5d5316cd..000000000 --- a/examples/broker/docker-compose.yml +++ /dev/null @@ -1,57 +0,0 @@ -version: '3.9' - -services: - # A PostgreSQL database for the Broker to store Pacts and verification results - postgres: - image: postgres - healthcheck: - test: psql postgres --command "select 1" -U postgres - ports: - - "5432:5432" - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - POSTGRES_DB: postgres - - # The Pact Broker - broker_app: - # Alternatively the DiUS Pact Broker can be used: - # image: dius/pact-broker - # - # As well as changing the image, the destination port will need to be changed - # from 9292 below, and in the nginx.conf proxy_pass section - image: pactfoundation/pact-broker - ports: - - "80:9292" - links: - - postgres - environment: - PACT_BROKER_DATABASE_USERNAME: postgres - PACT_BROKER_DATABASE_PASSWORD: password - PACT_BROKER_DATABASE_HOST: postgres - PACT_BROKER_DATABASE_NAME: postgres - PACT_BROKER_BASIC_AUTH_USERNAME: pactbroker - PACT_BROKER_BASIC_AUTH_PASSWORD: pactbroker - # The Pact Broker provides a healthcheck endpoint which we will use to wait - # for it to become available before starting up - healthcheck: - test: [ "CMD", "wget", "-q", "--tries=1", "--spider", "http://pactbroker:pactbroker@localhost:9292/diagnostic/status/heartbeat" ] - interval: 1s - timeout: 2s - retries: 5 - - # An NGINX reverse proxy in front of the Broker on port 8443, to be able to - # terminate with SSL - nginx: - image: nginx:alpine - links: - - broker_app:broker - volumes: - - ./ssl/nginx.conf:/etc/nginx/conf.d/default.conf:ro - - ./ssl:/etc/nginx/ssl - ports: - - "8443:443" - restart: always - depends_on: - broker_app: - condition: service_healthy diff --git a/examples/broker/ssl/nginx-selfsigned.crt b/examples/broker/ssl/nginx-selfsigned.crt deleted file mode 100644 index 144287f9c..000000000 --- a/examples/broker/ssl/nginx-selfsigned.crt +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDiDCCAnACCQCWW6LywpPSwjANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC -QVUxEzARBgNVBAgMClNvbWUgU3RhdGUxDzANBgNVBAcMBlN5ZG5leTENMAsGA1UE -CgwEUGFjdDEPMA0GA1UECwwGUHl0aG9uMRIwEAYDVQQDDAlsb2NhbGhvc3QxHDAa -BgkqhkiG9w0BCQEWDXNvbWVAbWFpbC5jb20wHhcNMjAwNjEwMTUzOTM2WhcNMjMw -MzMxMTUzOTM2WjCBhTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUgU3RhdGUx -DzANBgNVBAcMBlN5ZG5leTENMAsGA1UECgwEUGFjdDEPMA0GA1UECwwGUHl0aG9u -MRIwEAYDVQQDDAlsb2NhbGhvc3QxHDAaBgkqhkiG9w0BCQEWDXNvbWVAbWFpbC5j -b20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDm/BMUkuVaYwLjnoq/ -u4fFKoBGSPl3CxvSUWhzlsaM5i+UlS7ZLwXxAxw+Vba9cztSyYHNs2BCxCHUWBFe -B818cXzQXbV0gunMz9oDxr8aQmwpRkIdxxBvmaqLbk6sjj5cTqRK39/BNtZEkZmA -QAOggnfB7Bx/OQmh4aidT6DytjA8ur3FofAVUVXHfQohm/kJOhqcdXL5pBQqD2bh -Ua6KPbZTsfOmFLggZmhqPZSjS+leqFagpissW/aHSyk/3c+vhXOhEbCUeCXaz7up -/DNF/0OHF4+r2UaeonxMxC/X6NEhNYHyNPypbdC3/59Zoa2Spu2BLy8ZoChe1dRk -hZqtAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHpq3JmhAm5t/orY4ONFxPq1iF89 -3nKsKckfcOpDF/zjS2+6I30LVByuU88BKdTt7tsRojoWXEI01YGqYWTEwerfESr9 -M16xek5h5e7XJqp9jzyX6kswel/rWB8rF93biW0v00/KKRwwIr5IDvKb4XvugzW4 -FEG+1nhXCyjrkmKV/bbCfdkBHgavaj5TPv1LoXOX7VDRjwqoM7RP/z6JJsZkxDx3 -TkXtC8Lw4LF+tpWY8nQu3/HCqwxL7Vgy4M/IvoXRePdSI6goH8ri0zFuK9pvAREK -IjY271t+lapu8sDqUEf9tW/98YhxpBInQYBL2bEEtMYTRXRm06fSn7o3IlM= ------END CERTIFICATE----- diff --git a/examples/broker/ssl/nginx-selfsigned.key b/examples/broker/ssl/nginx-selfsigned.key deleted file mode 100644 index e8a6b5423..000000000 --- a/examples/broker/ssl/nginx-selfsigned.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpQIBAAKCAQEA5vwTFJLlWmMC456Kv7uHxSqARkj5dwsb0lFoc5bGjOYvlJUu -2S8F8QMcPlW2vXM7UsmBzbNgQsQh1FgRXgfNfHF80F21dILpzM/aA8a/GkJsKUZC -HccQb5mqi25OrI4+XE6kSt/fwTbWRJGZgEADoIJ3wewcfzkJoeGonU+g8rYwPLq9 -xaHwFVFVx30KIZv5CToanHVy+aQUKg9m4VGuij22U7HzphS4IGZoaj2Uo0vpXqhW -oKYrLFv2h0spP93Pr4VzoRGwlHgl2s+7qfwzRf9DhxePq9lGnqJ8TMQv1+jRITWB -8jT8qW3Qt/+fWaGtkqbtgS8vGaAoXtXUZIWarQIDAQABAoIBAQC3r5woz0yO3ZAN -nSWvpZ0pwUuzGRMxhOcCEPUkfrG0mNUbrqtL0WZDLHsIYzdoXzu88TxFbbFORxSz -/bkJ8uCJZuKf/PVxCy6MTnqMaD/OzSWgiRvI/GXoqeYC7ZypApE67NsgI/qXd1lb -vAG7CK0ZtscvsulSjvRHBOIG/6z5dUAKnLJjr7uKydMHSIKNafKAEA6HGDCvIu4d -J9EQzLfmpjLTkeB1DNZrv1mtNjf/kG/M/UX5a1RtOJTGvHQn/oZSUKng3DVUNBtq -dEO6Pi5n88xWuxH6YAWqqDjCfqyey1Jc1rQxfnx6vRPL7+IaXRugAKFMFm8Xbp9/ -/9eEDCyNAoGBAPZEjYH9u2856KYUTyky8gD1TOE9gf4x4zFjK6SzBT8v1y1RdSwQ -tf7ozj94OV/b9bAE3k/z2a09xYty5VBXs6MCluQTS67KgRaO9sSFtRmnupyBNk2z -r3QEYuVDmJ6Dk/3ovItXqFaW8IbOZMf6Acu5aEDx4UKmb2tzGGJ7DxF/AoGBAPAc -57p1yRWIG+hJMdkudXhBz+L3t2NbESWom33hi1mDMIKp3dwJmhA4kq+Uyqfl32uF -Iy3z+3xr2V1BdGg1RnicfcyjHaQ4/89YB+nkOHB8muV2R57tYahOgWn6rXXxTOBs -X2Vjd7ByAEFimrVfDH33inrYuIiI/cku4Xyj71HTAoGBAJeyrsBuPfFL6KW1SPYF -7dDtSchNjS+6J0sa3Z18sTS1EYVW8iiMuq8lVTb/pcgIxJUCyrbRbTssG+3EfsE4 -5Oz7AVvJDwvCrjXpJtTz0BTXnzoc1giTMPb0ZL75HqA2SQlVPh9PheCg5dUEekw9 -ErIdqbynwqy9vVCg+1pel2+dAoGAR1C+fsIHFG8VottCg/fpies6HHZosIjWwfGf -JTc9FTwCx3w+WeE8Mf8rihzOSCndPukPNtHVavH5YFpVgbH5GU+ZiZMU9ba8O9Aw -oYZYQQixVN/Zi9mDfOK8S0baCELAC5QEjW+KmAx0CPeJbb8qTaudJLmDrYHKpttW -u5dROGMCgYEAlgTZNiEeBAPQZD30CSvFUlZVCOOyu5crP9hCPA9um5FsvD9minSz -yJqeMj7zapZsatAzYwHrGG6nHnTKWEBNaimR7kjTpKdKzXQaA9XeVLmeFAZ3Exad -JDKTPI+asF+097sHUcVuloMOZXbD1uAZnvLWIwfsaHxs41AkF+0lmM4= ------END RSA PRIVATE KEY----- diff --git a/examples/broker/ssl/nginx.conf b/examples/broker/ssl/nginx.conf deleted file mode 100644 index e3c6bb36e..000000000 --- a/examples/broker/ssl/nginx.conf +++ /dev/null @@ -1,23 +0,0 @@ -server { - listen 443 ssl default_server; - server_name localhost; - ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt; - ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key; - ssl_protocols TLSv1 TLSv1.1 TLSv1.2; - ssl_prefer_server_ciphers on; - ssl_ecdh_curve secp384r1; - ssl_session_cache shared:SSL:10m; - ssl_stapling on; - ssl_stapling_verify on; - - location / { - # To use with the Dius Pact Broker: - # proxy_pass http://broker:80; - proxy_pass http://broker:9292; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Scheme "https"; - proxy_set_header X-Forwarded-Port "443"; - proxy_set_header X-Forwarded-Ssl "on"; - proxy_set_header X-Real-IP $remote_addr; - } -} diff --git a/examples/catalog/README.md b/examples/catalog/README.md new file mode 100644 index 000000000..304549a71 --- /dev/null +++ b/examples/catalog/README.md @@ -0,0 +1,34 @@ +# Pact Patterns Catalog + +This catalog contains focused, well-documented code snippets demonstrating specific Pact patterns and use cases. Unlike the full examples in the parent directory, catalog entries are designed to showcase a single pattern or technique with minimal application context. + +## Available + +- [Multipart Form Data with Matching Rules](./multipart_matching_rules/README.md) + +## Using Catalog Entries + +Catalog entries are intended as a reference for learning and adapting Pact patterns. To get the most value: + +- **Read the documentation** in each entry's README to understand the pattern and its intended use. +- **Review the code** to see how the pattern is implemented in practice. +- **Explore the tests** to see example usages and edge cases. + +If you want to experiment or adapt a pattern, you can run the tests for any entry: + +```console +cd examples/catalog +uv run --group test pytest +``` + +## Contributing Patterns + +When adding a new catalog entry: + +1. Focus on a single pattern or technique +2. Provide minimal but complete code, emphasizing the Pact aspects over application logic +3. Document the pattern thoroughly +4. Include working pytest tests +5. Add it to this README + +For complete examples with full application context, consider adding to the main examples directory instead. diff --git a/examples/catalog/__init__.py b/examples/catalog/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/examples/catalog/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/examples/catalog/multipart_matching_rules/README.md b/examples/catalog/multipart_matching_rules/README.md new file mode 100644 index 000000000..7a4cfe5e0 --- /dev/null +++ b/examples/catalog/multipart_matching_rules/README.md @@ -0,0 +1,11 @@ + +# Multipart Form Data with Matching Rules + +This entry demonstrates how to use Pact matching rules with multipart/form-data requests. Specifically, it demonstrates how multipart data is specified on the consumer side, and how matching rules can be applied to different parts of the multipart body. + +For full implementation details, see the code and docstrings in this directory. For general catalog usage, prerequisites, and test-running instructions, see the [main catalog README](../README.md). + +Related documentation: + +* [Pact Matching Rules](https://docs.pact.io/getting_started/matching) +* [Multipart Form Data (RFC 7578)](https://tools.ietf.org/html/rfc7578) diff --git a/examples/catalog/multipart_matching_rules/__init__.py b/examples/catalog/multipart_matching_rules/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/examples/catalog/multipart_matching_rules/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/examples/catalog/multipart_matching_rules/test_consumer.py b/examples/catalog/multipart_matching_rules/test_consumer.py new file mode 100644 index 000000000..f95d678ae --- /dev/null +++ b/examples/catalog/multipart_matching_rules/test_consumer.py @@ -0,0 +1,188 @@ +""" +Consumer test demonstrating multipart/form-data with matching rules. + +This test shows how to use Pact matching rules with multipart requests. The +examples illustrates this with a request containing both JSON metadata and +binary data (an image). The contract uses matching rules to validate +structure and types rather than exact values, allowing flexibility in the data +sent by the consumer and accepted by the provider. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +import httpx +import pytest + +from pact import Pact, match + +if TYPE_CHECKING: + from collections.abc import Generator + + +# Minimal JPEG for testing. The important part is the magic bytes. The rest is +# not strictly valid JPEG data. +# fmt: off +JPEG_BYTES = bytes([ + 0xFF, 0xD8, # Start of Image (SOI) marker + 0xFF, 0xE0, # JFIF APP0 Marker + 0x00, 0x10, # Length of APP0 segment + 0x4A, 0x46, 0x49, 0x46, 0x00, # "JFIF\0" + 0x01, 0x02, # Major and minor versions +]) +# fmt: on +""" +Some minimal JPEG bytes for testing multipart uploads. + +In this example, we only need the JPEG magic bytes to validate the file type. +This is not a complete JPEG file, but is sufficient for testing purposes. +""" + + +@pytest.fixture +def pact() -> Generator[Pact, None, None]: + """ + Set up Pact for consumer contract testing. + + This fixture initializes a Pact instance for the consumer tests, specifying + the consumer and provider names, and ensuring that the generated Pact files + are written to the appropriate directory after the tests run. + """ + pact = Pact( + "multipart-consumer", + "multipart-provider", + ) + yield pact + pact.write_file(Path(__file__).parents[1] / "pacts") + + +def test_multipart_upload_with_matching_rules(pact: Pact) -> None: + """ + Test multipart upload with matching of the contents. + + This test builds a `multipart/form-data` request by hand, and then uses a + library (`httpx`) to send the request to the mock server started by Pact. + Unlike simpler payloads, the matching rules _cannot_ be embedded within the + body itself. Instead, the body and matching rules are defined in separate + calls. + + Some key points about this example: + + - We use a matching rule for the `Content-Type` header to allow any valid + multipart boundary. This is important because many HTTP libraries + generate random boundaries automatically without user control. + - The body includes arbitrary binary data (a JPEG image) which cannot be + represented as a string. Therefore, it is critical that + `with_binary_body` is used to define the payload. + - Matching rules are defined for both the JSON metadata and the image part + to allow flexibility in the values sent by the consumer. The general + form to match a part within the multipart body is `$.`. So + to match a field in the `metadata` part, we use `$.metadata.`; or + to match the content type of the `image` part, we use `$.image`: + + ```python + from pact import match + + { + "body": { + "$.image": match.content_type("image/jpeg"), + "$.metadata.name": match.regex(regex=r"^[a-zA-Z]+$"), + }, + } + ``` + + /// warning + + Proper content types are essential when working with multipart data. This + ensures that Pact can correctly identify and apply matching rules to each + part of the multipart body. If content types are missing or incorrect, the + matching rules may not be applied as expected, leading to test failures or + incorrect behavior. + + /// + + To view the implementation, expand the source code below. + """ + # It is recommended to use a fixed boundary for testing, this ensures that + # the generated Pact is consistent across test runs. + boundary = "test-boundary-12345" + + metadata = { + "name": "test", + "size": 100, + } + + # Build multipart body with both JSON and binary parts. Note that since we + # are combining text and binary data, the strings must be encoded to bytes. + expected_body = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="metadata"\r\n' + f"Content-Type: application/json\r\n" + f"\r\n" + f"{json.dumps(metadata)}\r\n" + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="image"; filename="test.jpg"\r\n' + f"Content-Type: image/jpeg\r\n" + f"\r\n" + ).encode() + expected_body += JPEG_BYTES + expected_body += f"\r\n--{boundary}--\r\n".encode() + + # Define the interaction with matching rules + ( + pact + .upon_receiving("a multipart upload with JSON metadata and image") + .with_request("POST", "/upload") + .with_header( + "Content-Type", + # The matcher here is important if you don't have the ability to fix + # the boundary in the actual request (e.g., when using a library + # that generates it automatically). + match.regex( + f"multipart/form-data; boundary={boundary}", + regex=r"multipart/form-data;\s*boundary=.*", + ), + ) + .with_binary_body( + expected_body, + f"multipart/form-data; boundary={boundary}", + ) + # Matching rules make the contract flexible + .with_matching_rules({ + "body": { + "$.image": match.content_type("image/jpeg"), + "$.metadata": match.type({}), + "$.metadata.name": match.regex(regex=r"^[a-zA-Z]+$"), + "$.metadata.size": match.int(), + }, + }) + .will_respond_with(201) + .with_body({ + "id": "upload-1", + "message": "Upload successful", + "metadata": {"name": "test", "size": 100}, + "image_size": len(JPEG_BYTES), + }) + ) + + # Execute the test. Note that the matching rules take effect here, so we can + # send data that differs from the example in the contract. + with pact.serve() as srv: + # Simple inline consumer: just make the multipart request + files = { + "metadata": ( + None, + json.dumps({"name": "different", "size": 200}).encode(), + "application/json", + ), + "image": ("test.jpg", JPEG_BYTES, "image/jpeg"), + } + response = httpx.post(f"{srv.url}/upload", files=files, timeout=5) + + assert response.status_code == 201 + result = response.json() + assert result["message"] == "Upload successful" + assert result["id"] == "upload-1" diff --git a/examples/catalog/multipart_matching_rules/test_provider.py b/examples/catalog/multipart_matching_rules/test_provider.py new file mode 100644 index 000000000..efedc2952 --- /dev/null +++ b/examples/catalog/multipart_matching_rules/test_provider.py @@ -0,0 +1,140 @@ +""" +Provider verification for multipart/form-data contract with matching rules. + +This test demonstrates provider verification for contracts with multipart +requests and matching rules. It uses FastAPI to create a simple server that can +process multipart uploads containing JSON metadata and a JPEG image. The test +verifies that the provider complies with the contract generated by the consumer +tests, ensuring that it can handle variations in the data while maintaining +compatibility. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from threading import Thread +from typing import TYPE_CHECKING, Annotated + +import pytest +import uvicorn +from fastapi import FastAPI, Form, HTTPException, UploadFile +from pydantic import BaseModel + +import pact._util +from pact import Verifier + +if TYPE_CHECKING: + from typing import Any + + +# Simple FastAPI provider for handling multipart uploads +app = FastAPI() +""" +FastAPI application to handle multipart/form-data uploads. +""" + +uploads: dict[str, dict[str, Any]] = {} +""" +In-memory store for uploaded files and metadata. +""" + + +class UploadMetadata(BaseModel): + """ + Model for the JSON metadata part of the upload. + """ + + name: str + size: int + + +class UploadResponse(BaseModel): + """ + Model for the response returned after a successful upload. + """ + + id: str + message: str + metadata: UploadMetadata + image_size: int + + +@app.post("/upload", status_code=201) +async def upload_file( + metadata: Annotated[str, Form()], + image: Annotated[UploadFile, Form()], +) -> UploadResponse: + """ + Handle multipart upload with JSON metadata and image. + + This endpoint processes a multipart/form-data request containing a JSON + metadata part and an image file part. It validates the metadata structure + and the image content type, then stores the upload in memory. + """ + metadata_dict = UploadMetadata.model_validate_json(metadata) + if image.content_type != "image/jpeg": + msg = f"Expected image/jpeg, got {image.content_type}" + raise HTTPException(status_code=400, detail=msg) + + content = await image.read() + if not content.startswith((b"\xff\xd8\xff\xdb", b"\xff\xd8\xff\xe0")): + msg = "Invalid/malformed JPEG file" + raise HTTPException(status_code=400, detail=msg) + + upload_id = f"upload-{len(uploads) + 1}" + uploads[upload_id] = { + "id": upload_id, + "metadata": metadata_dict, + "filename": image.filename, + "content_type": image.content_type, + "size": len(content), + } + + return UploadResponse( + id=upload_id, + message="Upload successful", + metadata=metadata_dict, + image_size=len(content), + ) + + +@pytest.fixture +def app_server() -> str: + """ + Start FastAPI server for provider verification. + """ + hostname = "localhost" + port = pact._util.find_free_port() # noqa: SLF001 + Thread( + target=uvicorn.run, + args=(app,), + kwargs={"host": hostname, "port": port}, + daemon=True, + ).start() + time.sleep(0.1) # Allow server time to start + return f"http://{hostname}:{port}" + + +def test_provider_multipart(app_server: str) -> None: + """ + Verify the provider against the multipart upload contract. + + In general, there are no special considerations for verifying providers with + multipart requests. The Pact verifier will read the contract file generated + by the consumer tests and ensure that the provider can handle requests that + conform to the specified matching rules. + + As with any provider verification, the test needs to ensure that provider + states are set up correctly. This example does not include any provider + states to ensure simplicity. + """ + verifier = ( + Verifier("multipart-provider") + .add_source(Path(__file__).parents[1] / "pacts") + .add_transport(url=app_server) + ) + + verifier.verify() + + assert len(uploads) > 0, "No uploads were processed by the provider" diff --git a/examples/catalog/pyproject.toml b/examples/catalog/pyproject.toml new file mode 100644 index 000000000..7de8d8171 --- /dev/null +++ b/examples/catalog/pyproject.toml @@ -0,0 +1,31 @@ +#:schema https://www.schemastore.org/pyproject.toml + +[project] +name = "pact-python-catalog" +version = "1.0.0" + +dependencies = [ + "pact-python", + "httpx~=0.0", + "fastapi~=0.0", + "python-multipart~=0.0", +] +description = "Pact Python catalog: Well-documented patterns and use cases" +requires-python = ">=3.10" + +[dependency-groups] +test = ["pact-python", "pytest~=9.0", "uvicorn~=0.30"] + +[tool] + [tool.uv.sources] + pact-python = { path = "../../" } + + [tool.ruff] + extend = "../../pyproject.toml" + + [tool.pytest] + addopts = ["--import-mode=importlib"] + + log_date_format = "%H:%M:%S" + log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" + log_level = "NOTSET" diff --git a/examples/common/sharedfixtures.py b/examples/common/sharedfixtures.py deleted file mode 100644 index b430342f6..000000000 --- a/examples/common/sharedfixtures.py +++ /dev/null @@ -1,88 +0,0 @@ -import pathlib - -import docker -import pytest -from testcontainers.compose import DockerCompose - - -# This fixture is to simulate a managed Pact Broker or PactFlow account. -# For almost all purposes outside this example, you will want to use a real -# broker. See https://github.com/pact-foundation/pact_broker for further details. -@pytest.fixture(scope="session", autouse=True) -def broker(request): - version = request.config.getoption("--publish-pact") - publish = True if version else False - - # If the results are not going to be published to the broker, there is - # nothing further to do anyway - if not publish: - yield - return - - run_broker = request.config.getoption("--run-broker") - - if run_broker: - # Start up the broker using docker-compose - print("Starting broker") - with DockerCompose("../broker", compose_file_name=["docker-compose.yml"], pull=True) as compose: - stdout, stderr = compose.get_logs() - if stderr: - print("Errors\\n:{}".format(stderr)) - print("{}".format(stdout)) - print("Started broker") - - yield - print("Stopping broker") - print("Broker stopped") - else: - # Assuming there is a broker available already, docker-compose has been - # used manually as the --run-broker option has not been provided - yield - return - - -@pytest.fixture(scope="session", autouse=True) -def publish_existing_pact(broker): - """Publish the contents of the pacts folder to the Pact Broker. - - In normal usage, a Consumer would publish Pacts to the Pact Broker after - running tests - this fixture would NOT be needed. - . - Because the broker is being used standalone here, it will not contain the - required Pacts, so we must first spin up the pact-cli and publish them. - - In the Pact Broker logs, this corresponds to the following entry: - PactBroker::Pacts::Service -- Creating new pact publication with params \ - {:consumer_name=>"UserServiceClient", :provider_name=>"UserService", \ - :revision_number=>nil, :consumer_version_number=>"1", :pact_version_sha=>nil, \ - :consumer_name_in_pact=>"UserServiceClient", :provider_name_in_pact=>"UserService"} - """ - source = str(pathlib.Path.cwd().joinpath("..", "pacts").resolve()) - pacts = [f"{source}:/pacts"] - envs = { - "PACT_BROKER_BASE_URL": "http://broker_app:9292", - "PACT_BROKER_USERNAME": "pactbroker", - "PACT_BROKER_PASSWORD": "pactbroker", - } - - client = docker.from_env() - - print("Publishing existing Pact") - client.containers.run( - remove=True, - network="broker_default", - volumes=pacts, - image="pactfoundation/pact-cli:latest", - environment=envs, - command="publish /pacts --consumer-app-version 1", - ) - print("Finished publishing") - - -def pytest_addoption(parser): - parser.addoption( - "--publish-pact", type=str, action="store", help="Upload generated pact file to pact broker with version" - ) - - parser.addoption("--run-broker", type=bool, action="store", help="Whether to run broker in this test or not.") - parser.addoption("--provider-url", type=str, action="store", help="The url to our provider.") diff --git a/examples/conftest.py b/examples/conftest.py new file mode 100644 index 000000000..3ced5de4d --- /dev/null +++ b/examples/conftest.py @@ -0,0 +1,35 @@ +""" +Shared PyTest configuration. + +In order to run the examples, we need to run the Pact broker. In order to avoid +having to run the Pact broker manually, or repeating the same code in each +example, we define a PyTest fixture to run the Pact broker. + +We also define a `pact_dir` fixture to define the directory where the generated +Pact files will be stored. You are encouraged to have a look at these files +after the examples have been run. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import pact_ffi + +EXAMPLE_DIR = Path(__file__).parent.resolve() + + +@pytest.fixture(scope="session") +def pacts_path() -> Path: + """Fixture for the Pact directory.""" + return EXAMPLE_DIR / "pacts" + + +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + pact_ffi.log_to_stderr("INFO") diff --git a/examples/consumer/conftest.py b/examples/consumer/conftest.py deleted file mode 100644 index 90ac63b9a..000000000 --- a/examples/consumer/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import sys - -# Load in the fixtures from common/sharedfixtures.py -sys.path.append("../common") - -pytest_plugins = [ - "sharedfixtures", -] diff --git a/examples/consumer/requirements.txt b/examples/consumer/requirements.txt deleted file mode 100644 index c93ec0f76..000000000 --- a/examples/consumer/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -pytest==7.0.1; python_version < '3.7' -pytest==7.1.3; python_version >= '3.7' -requests==2.27.1; python_version < '3.7' -requests>=2.28.0; python_version >= '3.7' -testcontainers==3.7.0; python_version < '3.7' -testcontainers==3.7.1; python_version >= '3.7' diff --git a/examples/consumer/run_pytest.sh b/examples/consumer/run_pytest.sh deleted file mode 100755 index 7494398bb..000000000 --- a/examples/consumer/run_pytest.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -o pipefail - -pytest tests --run-broker True --publish-pact 1 \ No newline at end of file diff --git a/examples/consumer/src/consumer.py b/examples/consumer/src/consumer.py deleted file mode 100644 index 6d6ed3268..000000000 --- a/examples/consumer/src/consumer.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import Optional - -import requests -from datetime import datetime - - -class User(object): - """Define the basic User data we expect to receive from the User Provider.""" - - def __init__(self, name: str, created_on: str): - self.name = name - self.created_on = created_on - - -class UserConsumer(object): - """Demonstrate some basic functionality of how the User Consumer will interact - with the User Provider, in this case a simple get_user.""" - - def __init__(self, base_uri: str): - """Initialise the Consumer, in this case we only need to know the URI. - - :param base_uri: The full URI, including port of the Provider to connect to - """ - self.base_uri = base_uri - - def get_user(self, user_name: str) -> Optional[User]: - """Fetch a user object by user_name from the server. - - :param user_name: User name to search for - :return: User details if found, None if not found - """ - uri = self.base_uri + "/users/" + user_name - response = requests.get(uri) - if response.status_code == 404: - return None - - name = response.json()["name"] - created_on = datetime.strptime(response.json()["created_on"], "%Y-%m-%dT%H:%M:%S") - - return User(name, created_on) diff --git a/examples/consumer/tests/consumer/test_user_consumer.py b/examples/consumer/tests/consumer/test_user_consumer.py deleted file mode 100644 index 72ebaf589..000000000 --- a/examples/consumer/tests/consumer/test_user_consumer.py +++ /dev/null @@ -1,129 +0,0 @@ -"""pact test for user service client""" - -import atexit -import logging -import os - -import pytest - -from pact import Consumer, Like, Provider, Term, Format -from src.consumer import UserConsumer - -log = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - -# If publishing the Pact(s), they will be submitted to the Pact Broker here. -# For the purposes of this example, the broker is started up as a fixture defined -# in conftest.py. For normal usage this would be self-hosted or using PactFlow. -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" - -# Define where to run the mock server, for the consumer to connect to. These -# are the defaults so may be omitted -PACT_MOCK_HOST = "localhost" -PACT_MOCK_PORT = 1234 - -# Where to output the JSON Pact files created by any tests -PACT_DIR = os.path.dirname(os.path.realpath(__file__)) - - -@pytest.fixture -def consumer() -> UserConsumer: - return UserConsumer("http://{host}:{port}".format(host=PACT_MOCK_HOST, port=PACT_MOCK_PORT)) - - -@pytest.fixture(scope="session") -def pact(request): - """Setup a Pact Consumer, which provides the Provider mock service. This - will generate and optionally publish Pacts to the Pact Broker""" - - # When publishing a Pact to the Pact Broker, a version number of the Consumer - # is required, to be able to construct the compatability matrix between the - # Consumer versions and Provider versions - version = request.config.getoption("--publish-pact") - publish = True if version else False - - pact = Consumer("UserServiceClient", version=version).has_pact_with( - Provider("UserService"), - host_name=PACT_MOCK_HOST, - port=PACT_MOCK_PORT, - pact_dir=PACT_DIR, - publish_to_broker=publish, - broker_base_url=PACT_BROKER_URL, - broker_username=PACT_BROKER_USERNAME, - broker_password=PACT_BROKER_PASSWORD, - ) - - pact.start_service() - - # Make sure the Pact mocked provider is stopped when we finish, otherwise - # port 1234 may become blocked - atexit.register(pact.stop_service) - - yield pact - - # This will stop the Pact mock server, and if publish is True, submit Pacts - # to the Pact Broker - pact.stop_service() - - # Given we have cleanly stopped the service, we do not want to re-submit the - # Pacts to the Pact Broker again atexit, since the Broker may no longer be - # available if it has been started using the --run-broker option, as it will - # have been torn down at that point - pact.publish_to_broker = False - - -def test_get_user_non_admin(pact, consumer): - # Define the Matcher; the expected structure and content of the response - expected = { - "name": "UserA", - "id": Format().uuid, - "created_on": Term(r"\d+-\d+-\d+T\d+:\d+:\d+", "2016-12-15T20:16:01"), - "ip_address": Format().ip_address, - "admin": False, - } - - # Define the expected behaviour of the Provider. This determines how the - # Pact mock provider will behave. In this case, we expect a body which is - # "Like" the structure defined above. This means the mock provider will - # return the EXACT content where defined, e.g. UserA for name, and SOME - # appropriate content e.g. for ip_address. - ( - pact.given("UserA exists and is not an administrator") - .upon_receiving("a request for UserA") - .with_request("get", "/users/UserA") - .will_respond_with(200, body=Like(expected)) - ) - - with pact: - # Perform the actual request - user = consumer.get_user("UserA") - - # In this case the mock Provider will have returned a valid response - assert user.name == "UserA" - - # Make sure that all interactions defined occurred - pact.verify() - - -def test_get_non_existing_user(pact, consumer): - # Define the expected behaviour of the Provider. This determines how the - # Pact mock provider will behave. In this case, we expect a 404 - ( - pact.given("UserA does not exist") - .upon_receiving("a request for UserA") - .with_request("get", "/users/UserA") - .will_respond_with(404) - ) - - with pact: - # Perform the actual request - user = consumer.get_user("UserA") - - # In this case, the mock Provider will have returned a 404 so the - # consumer will have returned None - assert user is None - - # Make sure that all interactions defined occurred - pact.verify() diff --git a/examples/container-compose.yml b/examples/container-compose.yml new file mode 100644 index 000000000..d5796e003 --- /dev/null +++ b/examples/container-compose.yml @@ -0,0 +1,27 @@ +--- +services: + broker: + image: pactfoundation/pact-broker:latest-multi + ports: + - 9292:9292 + restart: always + environment: + # Basic auth credentials for the Broker + PACT_BROKER_ALLOW_PUBLIC_READ: 'true' + PACT_BROKER_BASIC_AUTH_USERNAME: pactbroker + PACT_BROKER_BASIC_AUTH_PASSWORD: pactbroker + # Database + PACT_BROKER_DATABASE_URL: sqlite:////tmp/pact_broker.sqlite + + healthcheck: + test: + - CMD + - wget + - -q + - -O + - '-' + - http://pactbroker:pactbroker@broker:9292/diagnostic/status/heartbeat + interval: 1s + timeout: 2s + retries: 5 + start_period: 30s diff --git a/examples/fastapi_provider/.flake8 b/examples/fastapi_provider/.flake8 deleted file mode 100644 index 5fb64e780..000000000 --- a/examples/fastapi_provider/.flake8 +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -max-line-length = 160 -exclude = .direnv/* -max-complexity = 10 diff --git a/examples/fastapi_provider/requirements.txt b/examples/fastapi_provider/requirements.txt deleted file mode 100644 index eea0e8724..000000000 --- a/examples/fastapi_provider/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -fastapi==0.67.0 -pytest==7.0.1; python_version < '3.7' -pytest==7.1.3; python_version >= '3.7' -requests==2.27.1; python_version < '3.7' -requests>=2.28.0; python_version >= '3.7' -uvicorn==0.16.0; python_version < '3.7' -uvicorn>=0.19.0; python_version >= '3.7' -testcontainers==3.7.0; python_version < '3.7' -testcontainers==3.7.1; python_version >= '3.7' diff --git a/examples/fastapi_provider/run_pytest.sh b/examples/fastapi_provider/run_pytest.sh deleted file mode 100755 index ff579b469..000000000 --- a/examples/fastapi_provider/run_pytest.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -set -o pipefail - -# Unlike in the Flask example, here the FastAPI service is started up as a pytest fixture. This is then including the -# main and pact routes via fastapi_provider.py to run the tests against -pytest --run-broker True --publish-pact 1 \ No newline at end of file diff --git a/examples/fastapi_provider/src/provider.py b/examples/fastapi_provider/src/provider.py deleted file mode 100644 index 206a87da8..000000000 --- a/examples/fastapi_provider/src/provider.py +++ /dev/null @@ -1,25 +0,0 @@ -import logging - -from fastapi import FastAPI, HTTPException, APIRouter -from fastapi.logger import logger - -fakedb = {} # Use a simple dict to represent a database - -logger.setLevel(logging.DEBUG) -router = APIRouter() -app = FastAPI() - - -@app.get("/users/{name}") -def get_user_by_name(name: str): - """Handle requests to retrieve a single user from the simulated database. - - :param name: Name of the user to "search for" - :return: The user data if found, HTTP 404 if not - """ - user_data = fakedb.get(name) - if not user_data: - logger.error(f"GET user for: '{name}', HTTP 404 not found") - raise HTTPException(status_code=404, detail="User not found") - logger.error(f"GET user for: '{name}', returning: {user_data}") - return user_data diff --git a/examples/fastapi_provider/tests/conftest.py b/examples/fastapi_provider/tests/conftest.py deleted file mode 100644 index 6e3176f20..000000000 --- a/examples/fastapi_provider/tests/conftest.py +++ /dev/null @@ -1,27 +0,0 @@ -import sys -from multiprocessing import Process - -import pytest - -from .pact_provider import run_server - -# Load in the fixtures from common/sharedfixtures.py -sys.path.append("../common") - -pytest_plugins = [ - "sharedfixtures", -] - - -@pytest.fixture(scope="module") -def server(): - proc = Process(target=run_server, args=(), daemon=True) - proc.start() - yield proc - - # Cleanup after test - if sys.version_info >= (3, 7): - # multiprocessing.kill is new in 3.7 - proc.kill() - else: - proc.terminate() diff --git a/examples/fastapi_provider/tests/pact_provider.py b/examples/fastapi_provider/tests/pact_provider.py deleted file mode 100644 index d778cd433..000000000 --- a/examples/fastapi_provider/tests/pact_provider.py +++ /dev/null @@ -1,46 +0,0 @@ -import uvicorn - -from fastapi import APIRouter -from pydantic import BaseModel - -from src.provider import app, fakedb, router as main_router - -pact_router = APIRouter() - - -class ProviderState(BaseModel): - state: str # noqa: E999 - - -@pact_router.post("/_pact/provider_states") -async def provider_states(provider_state: ProviderState): - mapping = { - "UserA does not exist": setup_no_user_a, - "UserA exists and is not an administrator": setup_user_a_nonadmin, - } - mapping[provider_state.state]() - - return {"result": mapping[provider_state.state]} - - -# Make sure the app includes both routers. This needs to be done after the -# declaration of the provider_states -app.include_router(main_router) -app.include_router(pact_router) - - -def run_server(): - uvicorn.run(app) - - -def setup_no_user_a(): - if "UserA" in fakedb: - del fakedb["UserA"] - - -def setup_user_a_nonadmin(): - id = "00000000-0000-4000-a000-000000000000" - some_date = "2016-12-15T20:16:01" - ip_address = "198.0.0.1" - - fakedb["UserA"] = {"name": "UserA", "id": id, "created_on": some_date, "ip_address": ip_address, "admin": False} diff --git a/examples/fastapi_provider/tests/provider/test_provider.py b/examples/fastapi_provider/tests/provider/test_provider.py deleted file mode 100644 index bc903b481..000000000 --- a/examples/fastapi_provider/tests/provider/test_provider.py +++ /dev/null @@ -1,87 +0,0 @@ -"""pact test for user service client""" -import logging - -import pytest - -from pact import Verifier - -log = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - - -# For the purposes of this example, the broker is started up as a fixture defined -# in conftest.py. For normal usage this would be self-hosted or using PactFlow. -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" - -# For the purposes of this example, the FastAPI provider will be started up as -# a fixture in conftest.py ("server"). Alternatives could be, for example -# running a Docker container with a database of test data configured. -# This is the "real" provider to verify against. -PROVIDER_HOST = "127.0.0.1" -PROVIDER_PORT = 8000 -PROVIDER_URL = f"http://{PROVIDER_HOST}:{PROVIDER_PORT}" - - -def test_success(): - pass - - -@pytest.fixture -def broker_opts(): - return { - "broker_username": PACT_BROKER_USERNAME, - "broker_password": PACT_BROKER_PASSWORD, - "broker_url": PACT_BROKER_URL, - "publish_version": "3", - "publish_verification_results": True, - } - - -def test_user_service_provider_against_broker(server, broker_opts): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) - - # Request all Pact(s) from the Pact Broker to verify this Provider against. - # In the Pact Broker logs, this corresponds to the following entry: - # PactBroker::Api::Resources::ProviderPactsForVerification -- Fetching pacts for verification by UserService -- {:provider_name=>"UserService", :params=>{}} - success, logs = verifier.verify_with_broker( - **broker_opts, - verbose=True, - provider_states_setup_url=f"{PROVIDER_URL}/_pact/provider_states", - enable_pending=False, - ) - # If publish_verification_results is set to True, the results will be - # published to the Pact Broker. - # In the Pact Broker logs, this corresponds to the following entry: - # PactBroker::Verifications::Service -- Creating verification 200 for \ - # pact_version_sha=c8568cbb30d2e3933b2df4d6e1248b3d37f3be34 -- \ - # {"success"=>true, "providerApplicationVersion"=>"3", "wip"=>false, \ - # "pending"=>"true"} - - # Note: - # If "successful", then the return code here will be 0 - # This can still be 0 and so PASS if a Pact verification FAILS, as long as - # it has not resulted in a REGRESSION of an already verified interaction. - # See https://docs.pact.io/pact_broker/advanced_topics/pending_pacts/ for - # more details. - assert success == 0 - - -def test_user_service_provider_against_pact(server): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) - - # Rather than requesting the Pact interactions from the Pact Broker, this - # will perform the verification based on the Pact file locally. - # - # Because there is no way of knowing the previous state of an interaction, - # if it has been successful in the past (since this is what the Pact Broker - # is for), if the verification of an interaction fails then the success - # result will be != 0, and so the test will FAIL. - output, _ = verifier.verify_pacts( - "../pacts/userserviceclient-userservice.json", - verbose=False, - provider_states_setup_url="{}/_pact/provider_states".format(PROVIDER_URL), - ) - - assert output == 0 diff --git a/examples/fastapi_provider/verify_pact.sh b/examples/fastapi_provider/verify_pact.sh deleted file mode 100755 index df0ec34a6..000000000 --- a/examples/fastapi_provider/verify_pact.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -set -o pipefail - -# Run the FastAPI server, using the pact_provider.py as the app to be able to -# inject the provider_states endpoint -uvicorn tests.pact_provider:app & &>/dev/null -FASTAPI_PID=$! - -# Make sure the FastAPI server is stopped when finished to avoid blocking the port -function teardown { - echo "Tearing down FastAPI server ${FASTAPI_PID}" - kill -9 $FASTAPI_PID -} -trap teardown EXIT - -# Wait a little in case FastAPI isn't quite ready -sleep 1 - -VERSION=$1 -if [ -x $VERSION ]; -then - echo "Validating provider locally" - - pact-verifier --provider-base-url=http://localhost:8000 \ - --provider-states-setup-url=http://localhost:8000/_pact/provider_states \ - ../pacts/userserviceclient-userservice.json -else - echo "Validating against Pact Broker" - - pact-verifier --provider-base-url=http://localhost:8000 \ - --provider-app-version $VERSION \ - --pact-url="http://127.0.0.1/pacts/provider/UserService/consumer/UserServiceClient/latest" \ - --pact-broker-username pactbroker \ - --pact-broker-password pactbroker \ - --publish-verification-results \ - --provider-states-setup-url=http://localhost:8000/_pact/provider_states -fi \ No newline at end of file diff --git a/examples/flask_provider/.flake8 b/examples/flask_provider/.flake8 deleted file mode 100644 index 5fb64e780..000000000 --- a/examples/flask_provider/.flake8 +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -max-line-length = 160 -exclude = .direnv/* -max-complexity = 10 diff --git a/examples/flask_provider/requirements.txt b/examples/flask_provider/requirements.txt deleted file mode 100644 index 6764a512b..000000000 --- a/examples/flask_provider/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -Flask==2.0.3; python_version < '3.7' -Flask==2.2.2; python_version >= '3.7' -pytest==7.0.1; python_version < '3.7' -pytest==7.1.3; python_version >= '3.7' -requests==2.27.1; python_version < '3.7' -requests>=2.28.0; python_version >= '3.7' -testcontainers==3.7.0; python_version < '3.7' -testcontainers==3.7.1; python_version >= '3.7' -markupsafe==2.0.1; python_version < '3.7' -markupsafe==2.1.2; python_version >= '3.7' diff --git a/examples/flask_provider/run_pytest.sh b/examples/flask_provider/run_pytest.sh deleted file mode 100755 index ca022085f..000000000 --- a/examples/flask_provider/run_pytest.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash -set -o pipefail - -# Run the Flask server, using the pact_provider.py as the app to be able to -# inject the provider_states endpoint -FLASK_APP=tests/pact_provider.py python -m flask run -p 5001 & -FLASK_PID=$! - -# Make sure the Flask server is stopped when finished to avoid blocking the port -function teardown { - echo "Tearing down Flask server: ${FLASK_PID}" - kill -9 $FLASK_PID -} -trap teardown EXIT - -# Wait a little in case Flask isn't quite ready -sleep 1 - -# Now run the tests -pytest tests --run-broker True --publish-pact 1 \ No newline at end of file diff --git a/examples/flask_provider/src/provider.py b/examples/flask_provider/src/provider.py deleted file mode 100644 index abdb4549f..000000000 --- a/examples/flask_provider/src/provider.py +++ /dev/null @@ -1,25 +0,0 @@ -from flask import Flask, abort, jsonify - -fakedb = {} # Use a simple dict to represent a database - -app = Flask(__name__) - - -@app.route("/users/") -def get_user_by_name(name: str): - """Handle requests to retrieve a single user from the simulated database. - - :param name: Name of the user to "search for" - :return: The user data if found, None (HTTP 404) if not - """ - user_data = fakedb.get(name) - if not user_data: - app.logger.debug(f"GET user for: '{name}', HTTP 404 not found") - abort(404) - response = jsonify(**user_data) - app.logger.debug(f"GET user for: '{name}', returning: {response.data}") - return response - - -if __name__ == "__main__": - app.run(debug=True, port=5001) diff --git a/examples/flask_provider/tests/conftest.py b/examples/flask_provider/tests/conftest.py deleted file mode 100644 index 90ac63b9a..000000000 --- a/examples/flask_provider/tests/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import sys - -# Load in the fixtures from common/sharedfixtures.py -sys.path.append("../common") - -pytest_plugins = [ - "sharedfixtures", -] diff --git a/examples/flask_provider/tests/pact_provider.py b/examples/flask_provider/tests/pact_provider.py deleted file mode 100644 index 0cb967486..000000000 --- a/examples/flask_provider/tests/pact_provider.py +++ /dev/null @@ -1,48 +0,0 @@ -"""additional endpoints to facilitate provider_states""" - -from flask import jsonify, request - -from src.provider import app, fakedb - - -@app.route("/_pact/provider_states", methods=["POST"]) -def provider_states(): - """Implement the "functionality" to change the state, to prepare for a test. - - When a Pact interaction is verified, it provides the "given" part of the - description from the Consumer in the X_PACT_PROVIDER_STATES header. - This can then be used to perform some operations on a database for example, - so that the actual request can be performed and respond as expected. - See: https://docs.pact.io/getting_started/provider_states - - This provider_states endpoint is deemed test only, and generally should not - be available once deployed to an environment. It would represent both a - potential data loss risk, as well as a security risk. - - As such, when running the Provider to test against, this is defined as the - FLASK_APP to run, adding this additional route to the app while keeping the - source separate. - """ - mapping = { - "UserA does not exist": setup_no_user_a, - "UserA exists and is not an administrator": setup_user_a_nonadmin, - } - mapping[request.json["state"]]() - return jsonify({"result": request.json["state"]}) - - -def setup_no_user_a(): - if "UserA" in fakedb: - del fakedb["UserA"] - - -def setup_user_a_nonadmin(): - id = "00000000-0000-4000-a000-000000000000" - some_date = "2016-12-15T20:16:01" - ip_address = "198.0.0.1" - - fakedb["UserA"] = {"name": "UserA", "id": id, "created_on": some_date, "ip_address": ip_address, "admin": False} - - -if __name__ == "__main__": - app.run(debug=True, port=5001) diff --git a/examples/flask_provider/tests/provider/test_provider.py b/examples/flask_provider/tests/provider/test_provider.py deleted file mode 100644 index f87257785..000000000 --- a/examples/flask_provider/tests/provider/test_provider.py +++ /dev/null @@ -1,83 +0,0 @@ -"""pact test for user service provider""" - -import logging - -import pytest - -from pact import Verifier - -log = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - -# For the purposes of this example, the broker is started up as a fixture defined -# in conftest.py. For normal usage this would be self-hosted or using PactFlow. -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" - -# For the purposes of this example, the Flask provider will be started up as part -# of run_pytest.sh when running the tests. Alternatives could be, for example -# running a Docker container with a database of test data configured. -# This is the "real" provider to verify against. -PROVIDER_HOST = "localhost" -PROVIDER_PORT = 5001 -PROVIDER_URL = f"http://{PROVIDER_HOST}:{PROVIDER_PORT}" - - -@pytest.fixture -def broker_opts(): - return { - "broker_username": PACT_BROKER_USERNAME, - "broker_password": PACT_BROKER_PASSWORD, - "broker_url": PACT_BROKER_URL, - "publish_version": "3", - "publish_verification_results": True, - } - - -def test_user_service_provider_against_broker(broker_opts): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) - - # Request all Pact(s) from the Pact Broker to verify this Provider against. - # In the Pact Broker logs, this corresponds to the following entry: - # PactBroker::Api::Resources::ProviderPactsForVerification -- Fetching pacts for verification by UserService -- {:provider_name=>"UserService", :params=>{}} - success, logs = verifier.verify_with_broker( - **broker_opts, - verbose=True, - provider_states_setup_url=f"{PROVIDER_URL}/_pact/provider_states", - enable_pending=False, - ) - # If publish_verification_results is set to True, the results will be - # published to the Pact Broker. - # In the Pact Broker logs, this corresponds to the following entry: - # PactBroker::Verifications::Service -- Creating verification 200 for \ - # pact_version_sha=c8568cbb30d2e3933b2df4d6e1248b3d37f3be34 -- \ - # {"success"=>true, "providerApplicationVersion"=>"3", "wip"=>false, \ - # "pending"=>"true"} - - # Note: - # If "successful", then the return code here will be 0 - # This can still be 0 and so PASS if a Pact verification FAILS, as long as - # it has not resulted in a REGRESSION of an already verified interaction. - # See https://docs.pact.io/pact_broker/advanced_topics/pending_pacts/ for - # more details. - assert success == 0 - - -def test_user_service_provider_against_pact(): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) - - # Rather than requesting the Pact interactions from the Pact Broker, this - # will perform the verification based on the Pact file locally. - # - # Because there is no way of knowing the previous state of an interaction, - # if it has been successful in the past (since this is what the Pact Broker - # is for), if the verification of an interaction fails then the success - # result will be != 0, and so the test will FAIL. - output, _ = verifier.verify_pacts( - "../pacts/userserviceclient-userservice.json", - verbose=False, - provider_states_setup_url="{}/_pact/provider_states".format(PROVIDER_URL), - ) - - assert output == 0 diff --git a/examples/flask_provider/verify_pact.sh b/examples/flask_provider/verify_pact.sh deleted file mode 100755 index f70629699..000000000 --- a/examples/flask_provider/verify_pact.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -set -o pipefail - -# Run the Flask server, using the pact_provider.py as the app to be able to -# inject the provider_states endpoint -FLASK_APP=tests/pact_provider.py python -m flask run -p 5001 & -FLASK_PID=$! - -# Make sure the Flask server is stopped when finished to avoid blocking the port -function teardown { - echo "Tearing down Flask server: ${FLASK_PID}" - kill -9 $FLASK_PID -} -trap teardown EXIT - -# Wait a little in case Flask isn't quite ready -sleep 1 - -VERSION=$1 -if [ -z "$VERSION" ]; -then - echo "Validating provider locally" - - pact-verifier \ - --provider-base-url=http://localhost:5001 \ - --provider-states-setup-url=http://localhost:5001/_pact/provider_states \ - ../pacts/userserviceclient-userservice.json -else - echo "Validating against Pact Broker" - - pact-verifier \ - --provider-base-url=http://localhost:5001 \ - --provider-app-version $VERSION \ - --pact-url="http://127.0.0.1/pacts/provider/UserService/consumer/UserServiceClient/latest" \ - --pact-broker-username pactbroker \ - --pact-broker-password pactbroker \ - --publish-verification-results \ - --provider-states-setup-url=http://localhost:5001/_pact/provider_states -fi diff --git a/examples/http/README.md b/examples/http/README.md new file mode 100644 index 000000000..aa7eb21a4 --- /dev/null +++ b/examples/http/README.md @@ -0,0 +1,10 @@ +# HTTP Examples + +This directory contains examples of HTTP-based contract testing with Pact. + +## Examples + +- [`aiohttp_and_flask/`](aiohttp_and_flask/) - Async aiohttp consumer with Flask provider +- [`requests_and_fastapi/`](requests_and_fastapi/) - requests consumer with FastAPI provider +- [`service_consumer_provider/`](service_consumer_provider/) - One service acting as both consumer and provider +- [`xml_example/`](xml_example/) - requests consumer with FastAPI provider using XML bodies diff --git a/examples/http/__init__.py b/examples/http/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/examples/http/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/examples/http/aiohttp_and_flask/README.md b/examples/http/aiohttp_and_flask/README.md new file mode 100644 index 000000000..1a6671a16 --- /dev/null +++ b/examples/http/aiohttp_and_flask/README.md @@ -0,0 +1,82 @@ +# aiohttp and Flask Example + +This example demonstrates contract testing between an asynchronous [`aiohttp`](https://docs.aiohttp.org/en/stable/)-based client (consumer) and a [Flask](https://flask.palletsprojects.com/en/stable/) web server (provider). It showcases modern Python patterns including async/await, type hints, and standalone dependency management. + +## Overview + +- [**Consumer**][examples.http.aiohttp_and_flask.consumer]: An async HTTP client using aiohttp +- [**Provider**][examples.http.aiohttp_and_flask.provider]: A Flask web server +- [**Consumer Tests**][examples.http.aiohttp_and_flask.test_consumer]: Contract definition and consumer testing +- [**Provider Tests**][examples.http.aiohttp_and_flask.test_provider]: Provider verification against contracts + +Use the above links to view additional documentation within. + +## What This Example Demonstrates + +### Consumer Side + +- Async HTTP client implementation with aiohttp +- Consumer contract testing with Pact mock servers +- Handling different HTTP response scenarios (success, not found, etc.) +- Modern Python async patterns + +### Provider Side + +- Flask web server with RESTful endpoints +- Provider verification against consumer contracts +- Provider state setup for different test scenarios +- Mock data management for testing + +### Testing Patterns + +- Independent consumer and provider testing +- Contract-driven development workflow +- Error handling and edge case testing +- Type safety with Python type hints + +## Prerequisites + +- Python 3.10 or higher +- A dependency manager ([uv](https://docs.astral.sh/uv/) recommended, [pip](https://pip.pypa.io/en/stable/) also works) + +## Running the Example + +### Using uv (Recommended) + +We recommend using [uv](https://docs.astral.sh/uv/) to manage the virtual env and manage dependencies. The following command will automatically set up the virtual environment, install dependencies, and then execute the command within the virtual environment: + +```console +uv run --group test pytest +``` + +### Using pip + +If using the [`venv`][venv] module, the steps require are: + +1. Create the virtual environment and then activate it: + + ```console + python -m venv .venv + source .venv/bin/activate # On macOS/Linux + .venv\Scripts\activate # On Windows + ``` + +2. Install the required dependencies in the virtual environment: + + ```console + pip install -U pip # Pip 25.1 is required + pip install --group test -e . + ``` + +3. Run pytest: + + ```console + pytest + ``` + +## Related Documentation + +- [Pact Documentation](https://docs.pact.io/) +- [aiohttp Documentation](https://docs.aiohttp.org/) +- [Flask Documentation](https://flask.palletsprojects.com/) +- [pytest Documentation](https://docs.pytest.org/) diff --git a/examples/http/aiohttp_and_flask/__init__.py b/examples/http/aiohttp_and_flask/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/examples/http/aiohttp_and_flask/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/examples/http/aiohttp_and_flask/conftest.py b/examples/http/aiohttp_and_flask/conftest.py new file mode 100644 index 000000000..d8c4a164c --- /dev/null +++ b/examples/http/aiohttp_and_flask/conftest.py @@ -0,0 +1,38 @@ +""" +Shared PyTest configuration. + +In order to run the examples, we need to run the Pact broker. In order to avoid +having to run the Pact broker manually, or repeating the same code in each +example, we define a PyTest fixture to run the Pact broker. + +We also define a `pact_dir` fixture to define the directory where the generated +Pact files will be stored. You are encouraged to have a look at these files +after the examples have been run. +""" + +from __future__ import annotations + +import contextlib +from pathlib import Path + +import pytest + +import pact_ffi + +EXAMPLE_DIR = Path(__file__).parent.resolve() + + +@pytest.fixture(scope="session") +def pacts_path() -> Path: + """Fixture for the Pact directory.""" + return EXAMPLE_DIR / "pacts" + + +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + # If the logger is already configured, this will raise a RuntimeError. + with contextlib.suppress(RuntimeError): + pact_ffi.log_to_stderr("INFO") diff --git a/examples/http/aiohttp_and_flask/consumer.py b/examples/http/aiohttp_and_flask/consumer.py new file mode 100644 index 000000000..5c50aaefe --- /dev/null +++ b/examples/http/aiohttp_and_flask/consumer.py @@ -0,0 +1,270 @@ +""" +Aiohttp consumer example. + +This modules defines a simple +[consumer](https://docs.pact.io/getting_started/terminology#service-consumer) +using the asynchronous [`aiohttp`][aiohttp] library which will be tested with +Pact in the [consumer test][examples.http.aiohttp_and_flask.test_consumer]. As +Pact is a consumer-driven framework, the consumer defines the interactions which +the provider must then satisfy. + +The consumer is the application which makes requests to another service (the +provider) and receives a response to process. In this example, we have a simple +`User` class and the consumer fetches a user's information from a HTTP endpoint. + +This also showcases how Pact tests differ from merely testing adherence to an +OpenAPI specification. The Pact tests are more concerned with the practical use +of the API, rather than the formally defined specification. So you will see +below that as far as this consumer is concerned, the only information needed +from the provider is the user's ID, name, and creation date. This is despite the +provider having additional fields in the response. + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +import logging +import sys +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING, Any + +import aiohttp + +if TYPE_CHECKING: + from types import TracebackType + + from typing_extensions import Self + +logger = logging.getLogger(__name__) + + +@dataclass() +class User: + """ + Represents a user as seen by the consumer. + + This class is intentionally minimal, including only the fields the consumer + actually uses. It may differ from the [provider's user + model][examples.http.aiohttp_and_flask.provider.User], which could have + additional fields. This demonstrates the consumer-driven nature of contract + testing: the consumer defines what it needs, not what the provider exposes. + """ + + id: int + name: str + created_on: datetime + + def __post_init__(self) -> None: + """ + Validate the user data. + + Ensures that the user has a non-empty name and a positive integer ID. + + Raises: + ValueError: + If the name is empty or the ID is not positive. + """ + if not self.name: + msg = "User must have a name" + raise ValueError(msg) + + if self.id <= 0: + msg = "User ID must be a positive integer" + raise ValueError(msg) + + def __repr__(self) -> str: + """ + Return a string representation of the user. + """ + return f"User(id={self.id!r}, name={self.name!r})" + + +class UserClient: + """ + HTTP client for interacting with a user provider service. + + This class is a simple consumer that fetches user data from a provider over + HTTP. It demonstrates how to structure consumer code for use in contract + testing, keeping it independent of Pact or any contract testing framework. + """ + + def __init__(self, hostname: str, base_path: str | None = None) -> None: + """ + Initialise the user client. + + Args: + hostname: + The base URL of the provider (must include scheme, e.g., + `http://`). + + base_path: + The base path for the provider's API endpoints. Defaults to `/`. + + Raises: + ValueError: + If the hostname does not start with `http://` or 'https://'. + """ + if not hostname.startswith(("http://", "https://")): + msg = "Invalid base URI" + raise ValueError(msg) + self._hostname = hostname + self._base_path = base_path or "/" + if not self._base_path.endswith("/"): + self._base_path += "/" + + self._session = aiohttp.ClientSession( + base_url=self._hostname, + timeout=aiohttp.ClientTimeout(total=5), + ) + logger.debug( + "Initialised UserClient with base URL: %s%s", + self.base_url, + self._base_path, + ) + + @property + def hostname(self) -> str: + """ + The hostname as a string. + + This includes the scheme. + """ + return self._hostname + + @property + def base_path(self) -> str: + """ + The base path as a string. + """ + return self._base_path + + @property + def base_url(self) -> str: + """ + The base URL as a string. + """ + return f"{self._hostname}{self._base_path}" + + async def __aenter__(self) -> Self: + """ + Begin an asynchronous context for the client. + """ + await self._session.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """ + Exit the asynchronous context for the client. + + Args: + exc_type: + The exception type, if any. + + exc_val: + The exception value, if any. + + exc_tb: + The traceback, if any. + """ + await self._session.__aexit__(exc_type, exc_val, exc_tb) + + async def get_user(self, user_id: int) -> User: + """ + Fetch a user by ID from the provider. + + This method demonstrates how a consumer fetches only the data it needs + from a provider, regardless of what else the provider may return. + + Args: + user_id: + The ID of the user to fetch. + + Returns: + A `User` instance representing the fetched user. + + Raises: + aiohttp.ClientError: + If the server returns a non-2xx response or the request fails. + """ + logger.debug("Fetching user %s", user_id) + async with self._session.get(f"{self.base_path}users/{user_id}") as response: + response.raise_for_status() + data: dict[str, Any] = await response.json() + + # Python < 3.11 don't support ISO 8601 offsets without a colon + if sys.version_info < (3, 11) and data["created_on"][-4:].isdigit(): + data["created_on"] = ( + data["created_on"][:-2] + ":" + data["created_on"][-2:] + ) + return User( + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), + ) + + async def create_user( + self, + *, + name: str, + ) -> User: + """ + Create a new user on the provider. + + Args: + name: + The name of the user to create. + + Returns: + A `User` instance representing the newly created user. + + Raises: + aiohttp.ClientError: + If the server returns a non-2xx response or the request fails. + """ + logger.debug("Creating user %s", name) + async with ( + self._session.post( + f"{self.base_path}users", json={"name": name} + ) as response, + ): + response.raise_for_status() + data = await response.json() + # Python < 3.11 don't support ISO 8601 offsets without a colon + if sys.version_info < (3, 11) and data["created_on"][-4:].isdigit(): + data["created_on"] = ( + data["created_on"][:-2] + ":" + data["created_on"][-2:] + ) + logger.debug("Created user %s", data["id"]) + return User( + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), + ) + + async def delete_user(self, uid: int | User) -> None: + """ + Delete a user by ID from the provider. + + Args: + uid: + The user ID (int) or a `User` instance to delete. + + Raises: + aiohttp.ClientError: + If the server returns a non-2xx response or the request fails. + """ + if isinstance(uid, User): + uid = uid.id + logger.debug("Deleting user %s", uid) + + async with self._session.delete(f"{self.base_path}users/{uid}") as response: + response.raise_for_status() diff --git a/examples/http/aiohttp_and_flask/provider.py b/examples/http/aiohttp_and_flask/provider.py new file mode 100644 index 000000000..694f70de4 --- /dev/null +++ b/examples/http/aiohttp_and_flask/provider.py @@ -0,0 +1,308 @@ +""" +Flask provider example. + +This modules defines a simple +[provider](https://docs.pact.io/getting_started/terminology#service-provider) +implemented with [`flask`][flask] which will be tested with Pact in the +[provider test][examples.http.aiohttp_and_flask.test_provider]. As Pact is a +consumer-driven framework, the consumer defines the contract which the provider +must then satisfy. + +The provider is the application which receives requests from another service +(the consumer) and returns a response. In this example, we have a simple +endpoint which returns a user's information from a (fake) database. + +This also showcases how Pact tests differ from merely testing adherence to an +OpenAPI specification. The Pact tests are more concerned with the practical use +of the API, rather than the formally defined specification. The User class +defined here has additional fields which are not used by the consumer. Should +the provider later decide to add or remove fields, Pact's consumer-driven +testing will provide feedback on whether the consumer is compatible with the +provider's changes. + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, ClassVar, Literal + +from flask import Flask, Response, abort, jsonify, request + +if TYPE_CHECKING: + import werkzeug.exceptions + +logger = logging.getLogger(__name__) + + +@dataclass() +class User: + """ + Represents a user in the provider system. + + This class is used to model user data as it might exist in a real + application. In a provider context, the data model may contain more fields + than are required by any single consumer. This example demonstrates how a + provider can serve multiple consumers with different data needs, and how + consumer-driven contract testing (such as with Pact) helps ensure + compatibility as the provider evolves. + """ + + id: int + name: str + created_on: datetime + email: str | None + ip_address: str | None + hobbies: list[str] + admin: bool + + def __post_init__(self) -> None: + """ + Validate the User data. + + Ensures that the user has a non-empty name and a positive integer ID. + + Raises: + ValueError: + If the name is empty or the ID is not positive. + """ + if not self.name: + msg = "User must have a name" + raise ValueError(msg) + + if self.id < 0: + msg = "User ID must be a positive integer" + raise ValueError(msg) + + def __repr__(self) -> str: + """ + Return a string representation of the user. + + Returns: + The user's name and ID as a string. + """ + return f"User(id={self.id!r}, name={self.name!r})" + + def to_dict(self) -> dict[str, Any]: + """ + Convert the user's data to a dictionary. + + Returns: + A dictionary containing the user's data, suitable for JSON + serialization. + """ + return { + "id": self.id, + "name": self.name, + "created_on": self.created_on.strftime("%Y-%m-%dT%H:%M:%S%z"), + "email": self.email, + "ip_address": self.ip_address, + "hobbies": self.hobbies, + "admin": self.admin, + } + + +class UserDb: + """ + A simple in-memory user database abstraction for demonstration purposes. + + This class simulates a user database using a class-level dictionary. In a + real application, this would interface with a persistent database or + external user service. For testing, calls to this class can be mocked to + avoid the need for a real database. See the [test + suite][examples.http.aiohttp_and_flask.test_provider] for an example. + """ + + _db: ClassVar[dict[int, User]] = {} + + @classmethod + def create(cls, user: User) -> None: + """ + Add a new user to the database. + + Args: + user: The User instance to add. + """ + cls._db[user.id] = user + + @classmethod + def update(cls, user: User) -> None: + """ + Update an existing user in the database. + + Args: + user: The User instance with updated data. + + Raises: + KeyError: If the user does not exist. + """ + if user.id not in cls._db: + msg = f"User with id {user.id} does not exist." + raise KeyError(msg) + cls._db[user.id] = user + + @classmethod + def delete(cls, user_id: int) -> None: + """ + Delete a user from the database by their ID. + + Args: + user_id: The ID of the user to delete. + + Raises: + KeyError: If the user does not exist. + """ + if user_id not in cls._db: + msg = f"User with id {user_id} does not exist." + raise KeyError(msg) + del cls._db[user_id] + + @classmethod + def get(cls, user_id: int) -> User | None: + """ + Retrieve a user by their ID. + + Args: + user_id: The ID of the user to retrieve. + + Returns: + The User instance if found, else None. + """ + return cls._db.get(user_id) + + @classmethod + def new_user_id(cls) -> int: + """ + Return a free user ID. + """ + return max(cls._db.keys(), default=0) + 1 + + +app = Flask(__name__) + + +@app.errorhandler(404) +def not_found(error: werkzeug.exceptions.NotFound) -> tuple[Response, Literal[404]]: + """ + Handle 404 Not Found errors. + + Args: + error: + The error that occurred. + + Returns: + A JSON response with error details and HTTP 404 status code. + """ + return jsonify({ + "title": "Not Found", + "status": 404, + "detail": error.description, + "instance": request.path, + }), 404 + + +@app.errorhandler(400) +def bad_request(error: werkzeug.exceptions.BadRequest) -> tuple[Response, Literal[400]]: + """ + Handle 400 Bad Request errors. + + Args: + error: + The error that occurred. + + Returns: + A JSON response with error details and HTTP 400 status code. + """ + return jsonify({ + "title": "Bad Request", + "status": 400, + "detail": error.description, + "instance": request.path, + }), 400 + + +@app.route("/users/") +def get_user_by_id(uid: int) -> Response: + """ + Retrieve a user by their ID. + + Args: + uid: + The ID of the user to fetch. + + Returns: + A JSON response containing the user data if found. + + Raises: + werkzeug.exceptions.NotFound: + If the user does not exist in the database. + """ + logger.debug("GET /users/%s", uid) + user = UserDb.get(uid) + if not user: + abort(404, description="User not found") + return jsonify(user.to_dict()) + + +@app.route("/users/", methods=["POST"]) +def create_user() -> tuple[Response, int]: + """ + Create a new user in the system. + + The user ID is automatically assigned. + + Returns: + A JSON response containing the created user data with HTTP 201 status + code. + + Raises: + werkzeug.exceptions.BadRequest: + If the request body is not valid JSON or required fields are + missing. + """ + logger.debug("GET /users/") + if request.json is None: + abort(400, description="Invalid JSON data") + + user: dict[str, Any] = request.json + new_user = User( + id=UserDb.new_user_id(), + name=user["name"], + created_on=datetime.now(tz=timezone.utc), + email=user.get("email"), + ip_address=user.get("ip_address"), + hobbies=user.get("hobbies", []), + admin=user.get("admin", False), + ) + UserDb.create(new_user) + return jsonify(new_user.to_dict()), 201 + + +@app.route("/users/", methods=["DELETE"]) +def delete_user(uid: int) -> tuple[str | Response, int]: + """ + Delete a user by their ID. + + If the user does not exist, a 404 error is returned. + + Args: + uid: + The ID of the user to delete. + + Returns: + An empty response with HTTP 204 status code if successful. + + Raises: + werkzeug.exceptions.NotFound: + If the user does not exist in the database. + """ + logger.debug("DELETE /users/%s", uid) + if UserDb.get(uid) is None: + abort(404, description="User not found") + UserDb.delete(uid) + return "", 204 diff --git a/examples/http/aiohttp_and_flask/pyproject.toml b/examples/http/aiohttp_and_flask/pyproject.toml new file mode 100644 index 000000000..dba9a40ea --- /dev/null +++ b/examples/http/aiohttp_and_flask/pyproject.toml @@ -0,0 +1,29 @@ +#:schema https://www.schemastore.org/pyproject.json + +[project] +name = "example-aiohttp-and-flask" + +description = "Example of using an aiohttp client and Flask server with Pact Python" + +version = "1.0.0" +requires-python = ">=3.10" +dependencies = ["aiohttp~=3.0", "flask~=3.0", "typing-extensions~=4.0"] + +[dependency-groups] +test = ["pact-python", "pytest~=9.0", "pytest-asyncio~=1.0"] + +[tool] + [tool.pytest] + addopts = ["--import-mode=importlib"] + + asyncio_default_fixture_loop_scope = "session" + + log_date_format = "%H:%M:%S" + log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" + log_level = "NOTSET" + + [tool.ruff] + extend = "../../../pyproject.toml" + + [tool.uv.sources] + pact-python = { path = "../../../" } diff --git a/examples/http/aiohttp_and_flask/test_consumer.py b/examples/http/aiohttp_and_flask/test_consumer.py new file mode 100644 index 000000000..e77e3f0fa --- /dev/null +++ b/examples/http/aiohttp_and_flask/test_consumer.py @@ -0,0 +1,165 @@ +""" +Consumer contract tests using Pact. + +This module demonstrates how to test a consumer (see +[`consumer.py`][examples.http.aiohttp_and_flask.consumer]) against a mock +provider using Pact. The mock provider is set up by Pact to validate that the +consumer makes the expected requests and can handle the provider's responses. +Once validated, the contract can be published to a Pact Broker for use in +provider verification. + +For more information on consumer testing with Pact, see the [Pact Consumer +Test](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-consumer-pact-test) +section of the Pact documentation. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +import aiohttp +import pytest + +from examples.http.aiohttp_and_flask.consumer import UserClient +from pact import Pact, match + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def pact(pacts_path: Path) -> Generator[Pact, None, None]: + """ + Set up a Pact mock provider for consumer tests. + + This fixture defines the consumer and provider, and sets up the mock + provider using Pact. Each test can then define the expected request and + response using the Pact DSL. This allows the consumer to be tested in + isolation from the real provider, ensuring that the contract is correct + before integration. + + Args: + pacts_path: + The path where the generated pact file will be written. + + Yields: + A Pact object for use in tests. + """ + pact = Pact("aiohttp-consumer", "flask-provider").with_specification("V4") + yield pact + pact.write_file(pacts_path) + + +@pytest.mark.asyncio +async def test_get_user(pact: Pact) -> None: + """ + Test the GET request for a user. + + This test defines the expected interaction for a GET request for a user. It + demonstrates how to use Pact to specify the expected request and response, + and how to verify that the consumer code can handle the response correctly. + """ + response: dict[str, object] = { + "id": match.int(123), + "name": match.str("Alice"), + "created_on": match.datetime(), + } + ( + pact + .upon_receiving("A user request") + .given("the user exists", id=123, name="Alice") + .with_request("GET", "/users/123") + .will_respond_with(200) + .with_body(response, content_type="application/json") + ) + + with pact.serve() as srv: + async with UserClient(str(srv.url)) as client: + user = await client.get_user(123) + assert user.name == "Alice" + + +@pytest.mark.asyncio +async def test_get_unknown_user(pact: Pact) -> None: + """ + Test the GET request for an unknown user. + + This test defines the expected interaction for a GET request for a user that + does not exist. It verifies that the consumer handles error responses as + expected. + """ + response = {"detail": "User not found"} + ( + pact + .upon_receiving("A request for an unknown user") + .given("the user doesn't exist", id=123) + .with_request("GET", "/users/123") + .will_respond_with(404) + .with_body(response, content_type="application/json") + ) + + with pact.serve() as srv: + async with UserClient(str(srv.url)) as client: + with pytest.raises(aiohttp.ClientError): + await client.get_user(123) + + +@pytest.mark.asyncio +async def test_create_user(pact: Pact) -> None: + """ + Test the POST request for creating a new user. + + This test defines the expected interaction for a POST request to create a + new user. It demonstrates how to specify the request and response, and how + to verify that the consumer can handle the provider's response. This also + shows how Pact can support multiple requests and responses within a single + test case. + """ + payload: dict[str, Any] = {"name": "Bob"} + response: dict[str, Any] = { + "id": match.int(1000), + "name": "Bob", + "created_on": match.datetime(datetime.now(tz=timezone.utc)), + } + + ( + pact + .upon_receiving("A request to create a new user") + .with_request("POST", "/users") + .with_body(payload, content_type="application/json") + .will_respond_with(201) + .with_body(response, content_type="application/json") + ) + + with pact.serve() as srv: + async with UserClient(str(srv.url)) as client: + user = await client.create_user(name="Bob") + assert user.id == 1000 + + +@pytest.mark.asyncio +async def test_delete_user(pact: Pact) -> None: + """ + Test the DELETE request for deleting a user. + + This test defines the expected interaction for a DELETE request to delete a + user. It demonstrates how to use Pact to specify the expected request and + response, and how to verify that the consumer code can handle the response + correctly. + """ + ( + pact + .upon_receiving("A user deletion request") + .given("the user exists", id=124, name="Bob") + .with_request("DELETE", "/users/124") + .will_respond_with(204) + ) + + with pact.serve() as srv: + async with UserClient(str(srv.url)) as client: + await client.delete_user(124) diff --git a/examples/http/aiohttp_and_flask/test_provider.py b/examples/http/aiohttp_and_flask/test_provider.py new file mode 100644 index 000000000..8c99e6f39 --- /dev/null +++ b/examples/http/aiohttp_and_flask/test_provider.py @@ -0,0 +1,226 @@ +""" +Provider contract tests using Pact. + +This module demonstrates how to test a Flask provider (see +[`provider.py`][examples.http.aiohttp_and_flask.provider]) against a mock +consumer using Pact. The mock consumer replays the requests defined by the +consumer contract, and Pact validates that the provider responds as expected. + +These tests show how provider verification ensures that the provider remains +compatible with the consumer contract as the provider evolves. Provider state +management is handled by mocking the database and using provider state +endpoints. For more, see the [Pact Provider +Test](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-provider-pact-test) +section of the Pact documentation. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import logging +from datetime import datetime, timezone +from threading import Thread +from typing import TYPE_CHECKING, Any, Literal + +import pytest + +import pact._util +from examples.http.aiohttp_and_flask.provider import User, UserDb, app +from pact import Verifier + +if TYPE_CHECKING: + from pathlib import Path + from typing import TypeAlias + + ACTION_TYPE: TypeAlias = Literal["setup", "teardown"] + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="session") +def app_server() -> str: + """ + Run the Flask server for provider verification. + + Returns: + The base URL of the running Flask server. + """ + hostname = "localhost" + port = pact._util.find_free_port() # noqa: SLF001 + Thread( + target=app.run, + kwargs={"host": hostname, "port": port}, + daemon=True, + ).start() + return f"http://{hostname}:{port}" + + +def test_provider(app_server: str, pacts_path: Path) -> None: + """ + Test the provider against the mock consumer contract. + + This test runs the Pact verifier against the Flask provider, using the + contract generated by the consumer tests. + + Provider state handlers are essential in Pact contract testing. They allow + the provider to be set up in a specific state before each interaction is + verified. For example, if a consumer expects a user to exist for a certain + request, the provider state handler ensures the database is populated + accordingly. This enables repeatable, isolated, and meaningful contract + verification, as each interaction can be tested in the correct context + without relying on global or persistent state. + + In this example, the state handlers `mock_user_exists` and + `mock_user_does_not_exist` are mapped to the states described in the + contract. They are responsible for setting up (and tearing down) the + in-memory database so that the provider can respond correctly to each + request defined by the consumer contract. + + For additional information on state handlers, see + [`Verifier.state_handler`][pact.verifier.Verifier.state_handler]. + """ + verifier = ( + Verifier("flask-provider") + .add_source(pacts_path) + .add_transport(url=app_server) + .state_handler( + { + "the user exists": mock_user_exists, + "the user doesn't exist": mock_user_does_not_exist, + }, + teardown=True, + ) + ) + + verifier.verify() + + +def default_mock_db() -> dict[int, User]: + """ + Standard in-memory database for provider state mocking. + + This function pre-populates a mock database with some default users. It is + used by the provider state handlers to ensure that the database is in the + correct state for each interaction. + + Returns: + A dictionary of user IDs to User objects for use in tests. + """ + return { + 1: User( + id=1, + name="Alice", + email="alice@example.com", + created_on=datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + ip_address="1.2.3.4", + hobbies=["pact testing", "programming", "qa"], + admin=False, + ), + 2: User( + id=2, + name="Bob", + email=None, # Edge case: email is None + created_on=datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc), + ip_address="", + hobbies=[], + admin=True, + ), + 10: User( + id=10, + name="Charlie", + email="charlie@example.com", + created_on=datetime(2025, 8, 8, 8, 8, 8, tzinfo=timezone.utc), + ip_address="3.4.5.6", + hobbies=[""], + admin=False, + ), + 42: User( + id=42, + name="Dana", + email="dana+test@example.com", + created_on=datetime(2022, 2, 22, 2, 22, 22, tzinfo=timezone.utc), + ip_address="255.255.255.255", + hobbies=["edge", "case", "testing"], + admin=False, + ), + } + + +def mock_user_exists( + action: Literal["setup", "teardown"], + parameters: dict[str, Any], +) -> None: + """ + Mock the provider state where a user exists. + + This handler sets up the provider so that a user with the given ID exists in + the database. Used by Pact to ensure the provider is in the correct state + for each interaction. + + Args: + action: + The action to perform, either "setup" or "teardown". + + parameters: + User information, including an `id` to guarantee presence in the + database. Additional fields may be provided to override defaults. + """ + logger.debug("mock_user_exists(%s, %r)", action, parameters) + + # We pre-populate the database with some data, and if a state requires + # some specific data, ensure the user is present. + db = default_mock_db() + user = db[uid] if (uid := parameters.get("id")) in db else next(iter(db.values())) + user = User(**{**dataclasses.asdict(user), **parameters}) + + if action == "setup": + UserDb.create(user) + return + + if action == "teardown": + with contextlib.suppress(KeyError): + UserDb.delete(user.id) + return + + msg = f"Unknown action: {action}" + raise ValueError(msg) + + +def mock_user_does_not_exist( + action: Literal["setup", "teardown"], + parameters: dict[str, Any], +) -> None: + """ + Mock the provider state where a user does not exist. + + This handler sets up the provider so that a user with the given ID does not + exist in the database. Used by Pact to ensure the provider is in the correct + state for each interaction. + + Args: + action: + The action to perform, either "setup" or "teardown". + + parameters: + User information, must contain an `id` to guarantee absence in the + database. + """ + logger.debug("mock_user_does_not_exist(%s, %r)", action, parameters) + + if "id" not in parameters: + msg = "State must contain an 'id' field to mock user non-existence" + raise ValueError(msg) + + uid = parameters["id"] + + if action == "setup": + if user := UserDb.get(uid): + UserDb.delete(user.id) + return + + if action == "teardown": + return + + msg = f"Unknown action: {action}" + raise ValueError(msg) diff --git a/examples/http/requests_and_fastapi/README.md b/examples/http/requests_and_fastapi/README.md new file mode 100644 index 000000000..1b8a4e08a --- /dev/null +++ b/examples/http/requests_and_fastapi/README.md @@ -0,0 +1,91 @@ +# Example: requests Client and FastAPI Provider with Pact Contract Testing + +This example demonstrates contract testing between a synchronous [`requests`](https://docs.python-requests.org/en/latest/)-based client (consumer) and a [FastAPI](https://fastapi.tiangolo.com/) web server (provider). It is designed to be pedagogical, showing modern Python patterns, type hints, and best practices for contract-driven development. + +## Overview + +- [**Consumer**][examples.http.requests_and_fastapi.consumer]: Synchronous HTTP client using requests +- [**Provider**][examples.http.requests_and_fastapi.provider]: FastAPI web server +- [**Consumer Tests**][examples.http.requests_and_fastapi.test_consumer]: Contract definition and consumer testing with Pact +- [**Provider Tests**][examples.http.requests_and_fastapi.test_provider]: Provider verification against contracts + +Use the above links to view additional documentation within. + +## What This Example Demonstrates + +### Consumer Side + +- Synchronous HTTP client implementation with requests +- Consumer contract testing with Pact mock servers +- Handling different HTTP response scenarios (success, not found, etc.) +- Modern Python patterns and type hints + +### Provider Side + +- FastAPI web server with RESTful endpoints +- Provider verification against consumer contracts +- Provider state setup for different test scenarios +- Mock data management for testing + +### Testing Patterns + +- Independent consumer and provider testing +- Contract-driven development workflow +- Error handling and edge case testing +- Type safety with Python type hints + +## Pedagogical Context + +This example is intended for software engineers and engineering managers who want to understand: + +- How contract testing works in practice +- Why consumer-driven contracts are valuable +- How to structure Python code for clarity and testability +- The benefits of using requests and FastAPI for simple, modern HTTP services + +## Prerequisites + +- Python 3.10 or higher +- A dependency manager ([uv](https://docs.astral.sh/uv/) recommended, [pip](https://pip.pypa.io/en/stable/) also works) + +## Running the Example + +### Using uv (Recommended) + +We recommend using [uv](https://docs.astral.sh/uv/) to manage the virtual env and manage dependencies. The following command will automatically set up the virtual environment, install dependencies, and then execute the command within the virtual environment: + +```console +uv run --group test pytest +``` + +### Using pip + +If using the [`venv`][venv] module, the steps require are: + +1. Create and activate a virtual environment: + + ```console + python -m venv .venv + source .venv/bin/activate # On macOS/Linux + .venv\Scripts\activate # On Windows + ``` + +2. Install dependencies: + + ```console + pip install -U pip # Pip 25.1 is required + pip install --group test -e . + ``` + +3. Run tests: + + ```console + pytest + ``` + +## Related Documentation + +- [Pact Documentation](https://docs.pact.io/) +- [requests Documentation](https://docs.python-requests.org/) +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [pytest Documentation](https://docs.pytest.org/) diff --git a/examples/http/requests_and_fastapi/__init__.py b/examples/http/requests_and_fastapi/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/examples/http/requests_and_fastapi/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/examples/http/requests_and_fastapi/conftest.py b/examples/http/requests_and_fastapi/conftest.py new file mode 100644 index 000000000..d8c4a164c --- /dev/null +++ b/examples/http/requests_and_fastapi/conftest.py @@ -0,0 +1,38 @@ +""" +Shared PyTest configuration. + +In order to run the examples, we need to run the Pact broker. In order to avoid +having to run the Pact broker manually, or repeating the same code in each +example, we define a PyTest fixture to run the Pact broker. + +We also define a `pact_dir` fixture to define the directory where the generated +Pact files will be stored. You are encouraged to have a look at these files +after the examples have been run. +""" + +from __future__ import annotations + +import contextlib +from pathlib import Path + +import pytest + +import pact_ffi + +EXAMPLE_DIR = Path(__file__).parent.resolve() + + +@pytest.fixture(scope="session") +def pacts_path() -> Path: + """Fixture for the Pact directory.""" + return EXAMPLE_DIR / "pacts" + + +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + # If the logger is already configured, this will raise a RuntimeError. + with contextlib.suppress(RuntimeError): + pact_ffi.log_to_stderr("INFO") diff --git a/examples/http/requests_and_fastapi/consumer.py b/examples/http/requests_and_fastapi/consumer.py new file mode 100644 index 000000000..a25392fab --- /dev/null +++ b/examples/http/requests_and_fastapi/consumer.py @@ -0,0 +1,262 @@ +""" +Requests consumer example. + +This module defines a simple +[consumer](https://docs.pact.io/getting_started/terminology#service-consumer) +using the synchronous [`requests`][requests] library which will be tested with +Pact in the [consumer test][examples.http.requests_and_fastapi.test_consumer]. +As Pact is a consumer-driven framework, the consumer defines the interactions +which the provider must then satisfy. + +The consumer is the application which makes requests to another service (the +provider) and receives a response to process. In this example, we have a simple +`User` class and the consumer fetches a user's information from a HTTP endpoint. + +This also showcases how Pact tests differ from merely testing adherence to an +OpenAPI specification. The Pact tests are more concerned with the practical use +of the API, rather than the formally defined specification. So you will see +below that as far as this consumer is concerned, the only information needed +from the provider is the user's ID, name, and creation date. This is despite the +provider having additional fields in the response. + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +import logging +import sys +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING, Any + +import requests + +if TYPE_CHECKING: + from types import TracebackType + + from typing_extensions import Self + +logger = logging.getLogger(__name__) + + +@dataclass() +class User: + """ + Represents a user as seen by the consumer. + + This class is intentionally minimal, including only the fields the consumer + actually uses. It may differ from the [provider's user + model][examples.http.requests_and_fastapi.provider.User], which could have + additional fields. This demonstrates the consumer-driven nature of contract + testing: the consumer defines what it needs, not what the provider exposes. + """ + + id: int + name: str + created_on: datetime + + def __post_init__(self) -> None: + """ + Validate the user data for contract and business logic. + + Ensures that the user has a non-empty name and a positive integer ID. + + Raises: + ValueError: If the name is empty or the ID is not positive. + """ + if not self.name: + msg = "User must have a name" + raise ValueError(msg) + + if self.id <= 0: + msg = "User ID must be a positive integer" + raise ValueError(msg) + + def __repr__(self) -> str: + """ + Return a string representation of the user. + """ + return f"User(id={self.id!r}, name={self.name!r})" + + +class UserClient: + """ + HTTP client for interacting with a user provider service. + + This class is a simple consumer that fetches user data from a provider over + HTTP. It demonstrates how to structure consumer code for use in contract + testing, keeping it independent of Pact or any contract testing framework. + """ + + def __init__(self, hostname: str, base_path: str | None = None) -> None: + """ + Initialise the user client. + + Args: + hostname: + The base URL of the provider (must include scheme, e.g., + `http://`). + + base_path: + The base path for the provider's API endpoints. Defaults to `/`. + + Raises: + ValueError: + If the hostname does not start with 'http://' or `https://`. + """ + if not hostname.startswith(("http://", "https://")): + msg = "Invalid base URI" + raise ValueError(msg) + self._hostname = hostname + self._base_path = base_path or "/" + if not self._base_path.endswith("/"): + self._base_path += "/" + + self._session = requests.Session() + logger.debug( + "Initialised UserClient with base URL: %s%s", + self.base_url, + self._base_path, + ) + + @property + def hostname(self) -> str: + """ + The hostname as a string. + + This includes the scheme. + """ + return self._hostname + + @property + def base_path(self) -> str: + """ + The base path as a string. + """ + return self._base_path + + @property + def base_url(self) -> str: + """ + The base URL as a string. + """ + return f"{self._hostname}{self._base_path}" + + def __enter__(self) -> Self: + """ + Begin the context for the client. + """ + self._session.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """ + Exit the context for the client. + + Args: + exc_type: + The exception type, if any. + + exc_val: + The exception value, if any. + + exc_tb: + The traceback, if any. + + """ + self._session.__exit__(exc_type, exc_val, exc_tb) + + def get_user(self, user_id: int) -> User: + """ + Fetch a user by ID from the provider. + + This method demonstrates how a consumer fetches only the data it needs + from a provider, regardless of what else the provider may return. + + Args: + user_id: + The ID of the user to fetch. + + Returns: + A `User` instance representing the fetched user. + + Raises: + requests.HTTPError: + If the server returns a non-2xx response or the request fails. + """ + logger.debug("Fetching user %s", user_id) + response = self._session.get(f"{self.hostname}{self.base_path}users/{user_id}") + response.raise_for_status() + data: dict[str, Any] = response.json() + + # Python < 3.11 don't support ISO 8601 offsets without a colon + if sys.version_info < (3, 11) and data["created_on"][-4:].isdigit(): + data["created_on"] = data["created_on"][:-2] + ":" + data["created_on"][-2:] + return User( + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), + ) + + def create_user( + self, + *, + name: str, + ) -> User: + """ + Create a new user on the provider. + + Args: + name: + The name of the user to create. + + Returns: + A `User` instance representing the newly created user. + + Raises: + requests.HTTPError: + If the server returns a non-2xx response or the request fails. + """ + logger.debug("Creating user %s", name) + response = self._session.post( + f"{self.hostname}{self.base_path}users", + json={"name": name}, + ) + response.raise_for_status() + data = response.json() + + # Python < 3.11 don't support ISO 8601 offsets without a colon + if sys.version_info < (3, 11) and data["created_on"][-4:].isdigit(): + data["created_on"] = data["created_on"][:-2] + ":" + data["created_on"][-2:] + logger.debug("Created user %s", data["id"]) + return User( + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), + ) + + def delete_user(self, uid: int | User) -> None: + """ + Delete a user by ID from the provider. + + Args: + uid: + The user ID (int) or a `User` instance to delete. + + Raises: + requests.HTTPError: + If the server returns a non-2xx response or the request fails. + """ + if isinstance(uid, User): + uid = uid.id + logger.debug("Deleting user %s", uid) + response = self._session.delete(f"{self.hostname}{self.base_path}users/{uid}") + response.raise_for_status() diff --git a/examples/http/requests_and_fastapi/provider.py b/examples/http/requests_and_fastapi/provider.py new file mode 100644 index 000000000..1275a3806 --- /dev/null +++ b/examples/http/requests_and_fastapi/provider.py @@ -0,0 +1,223 @@ +""" +FastAPI provider example. + +This modules defines a simple +[provider](https://docs.pact.io/getting_started/terminology#service-provider) +implemented with [`fastapi`](https://fastapi.tiangolo.com/) which will be tested +with Pact in the [provider +test][examples.http.requests_and_fastapi.test_provider]. As Pact is a +consumer-driven framework, the consumer defines the contract which the provider +must then satisfy. + +The provider is the application which receives requests from another service +(the consumer) and returns a response. In this example, we have a simple +endpoint which returns a user's information from a (fake) database. + +This also showcases how Pact tests differ from merely testing adherence to an +OpenAPI specification. The Pact tests are more concerned with the practical use +of the API, rather than the formally defined specification. The User class +defined here has additional fields which are not used by the consumer. Should +the provider later decide to add or remove fields, Pact's consumer-driven +testing will provide feedback on whether the consumer is compatible with the +provider's changes. + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any, ClassVar + +from fastapi import FastAPI, HTTPException, status +from pydantic import BaseModel, Field, field_validator + +logger = logging.getLogger(__name__) + + +class User(BaseModel): + """ + Represents a user in the provider system. + + This class models user data as it might exist in a real application. In a + provider context, the data model may contain more fields than are required + by any single consumer. This example demonstrates how a provider can serve + multiple consumers with different data needs, and how consumer-driven + contract testing (such as with Pact) helps ensure compatibility as the + provider evolves. + """ + + id: int + name: str + created_on: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc)) + email: str | None = None + ip_address: str | None = None + hobbies: list[str] = Field(default_factory=list) + admin: bool = False + + @field_validator("id") + @classmethod + def validate_id(cls, value: int) -> int: + """ + Ensure the ID is a positive integer. + """ + if value <= 0: + msg = "ID must be a positive integer" + raise ValueError(msg) + return value + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + """ + Ensure the name is not empty. + """ + if not value: + msg = "Name must not be empty" + raise ValueError(msg) + return value + + +class UserDb: + """ + A simple in-memory user database abstraction for demonstration purposes. + + This class simulates a user database using a class-level dictionary. In a + real application, this would interface with a persistent database or + external user service. For testing, calls to this class can be mocked to + avoid the need for a real database. See the [test + suite][examples.http.requests_and_fastapi.test_provider] for an example. + """ + + _db: ClassVar[dict[int, User]] = {} + + @classmethod + def create(cls, user: User) -> None: + """ + Add a new user to the database. + + Args: + user: The User instance to add. + """ + cls._db[user.id] = user + + @classmethod + def update(cls, user: User) -> None: + """ + Update an existing user in the database. + + Args: + user: The User instance with updated data. + + Raises: + KeyError: If the user does not exist. + """ + if user.id not in cls._db: + msg = f"User with id {user.id} does not exist." + raise KeyError(msg) + cls._db[user.id] = user + + @classmethod + def delete(cls, user_id: int) -> None: + """ + Delete a user from the database by their ID. + + Args: + user_id: The ID of the user to delete. + + Raises: + KeyError: If the user does not exist. + """ + if user_id not in cls._db: + msg = f"User with id {user_id} does not exist." + raise KeyError(msg) + del cls._db[user_id] + + @classmethod + def get(cls, user_id: int) -> User | None: + """ + Retrieve a user by their ID. + + Args: + user_id: The ID of the user to retrieve. + + Returns: + The User instance if found, else None. + """ + return cls._db.get(user_id) + + @classmethod + def new_user_id(cls) -> int: + """ + Return a free user ID. + """ + return max(cls._db.keys(), default=0) + 1 + + +app = FastAPI() + + +@app.get("/users/{uid}") +async def get_user_by_id(uid: int) -> User: + """ + Retrieve a user by their ID. + + Args: + uid: + The user ID to retrieve. + + Returns: + A User instance representing the user with the given ID. + + Raises: + HTTPException: If the user is not found, a 404 error is raised. + """ + logger.debug("GET /users/%s", uid) + user = UserDb.get(uid) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@app.post("/users/", status_code=status.HTTP_201_CREATED) +async def create_user(data: dict[str, Any]) -> User: + """ + Create a new user in the system. + """ + logger.debug("POST /users/") + + if not data or "name" not in data: + raise HTTPException(status_code=400, detail="Invalid JSON data") + + user = User( + id=UserDb.new_user_id(), + name=data["name"], + created_on=datetime.now(tz=timezone.utc), + email=data.get("email"), + ip_address=data.get("ip_address"), + hobbies=data.get("hobbies", []), + admin=data.get("admin", False), + ) + UserDb.create(user) + return user + + +@app.delete("/users/{uid}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user(uid: int): # noqa: ANN201 + """ + Delete a user by their ID. + + Args: + uid: + The user ID to delete. + + Raises: + HTTPException: If the user is not found, a 404 error is raised. + """ + logger.debug("DELETE /users/%s", uid) + if UserDb.get(uid) is None: + raise HTTPException(status_code=404, detail="User not found") + UserDb.delete(uid) diff --git a/examples/http/requests_and_fastapi/pyproject.toml b/examples/http/requests_and_fastapi/pyproject.toml new file mode 100644 index 000000000..03b32dfdf --- /dev/null +++ b/examples/http/requests_and_fastapi/pyproject.toml @@ -0,0 +1,29 @@ +#:schema https://www.schemastore.org/pyproject.json + +[project] +name = "example-requests-and-fastapi" + +description = "Example of using a requests client and FastAPI server with Pact Python" + +version = "1.0.0" +requires-python = ">=3.10" +dependencies = ["fastapi~=0.0", "requests~=2.0", "typing-extensions~=4.0"] + +[dependency-groups] +test = ["pact-python", "pytest~=9.0", "uvicorn~=0.29"] + +[tool] + [tool.pytest] + addopts = ["--import-mode=importlib"] + + asyncio_default_fixture_loop_scope = "session" + + log_date_format = "%H:%M:%S" + log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" + log_level = "NOTSET" + + [tool.ruff] + extend = "../../../pyproject.toml" + + [tool.uv.sources] + pact-python = { path = "../../../" } diff --git a/examples/http/requests_and_fastapi/test_consumer.py b/examples/http/requests_and_fastapi/test_consumer.py new file mode 100644 index 000000000..6f69a8c64 --- /dev/null +++ b/examples/http/requests_and_fastapi/test_consumer.py @@ -0,0 +1,163 @@ +""" +Consumer contract tests using Pact. + +This module demonstrates how to test a consumer (see +[`consumer.py`][examples.http.requests_and_fastapi.consumer]) against a mock +provider using Pact. The mock provider is set up by Pact to validate that the +consumer makes the expected requests and can handle the provider's responses. +Once validated, the contract can be published to a Pact Broker for use in +provider verification. + +For more information on consumer testing with Pact, see the [Pact Consumer +Test](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-consumer-pact-test) +section of the Pact documentation. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +import pytest +import requests + +from examples.http.requests_and_fastapi.consumer import UserClient +from pact import Pact, match + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def pact(pacts_path: Path) -> Generator[Pact, None, None]: + """ + Set up a Pact mock provider for consumer tests. + + This fixture defines the consumer and provider, and sets up the mock + provider using Pact. Each test can then define the expected request and + response using the Pact DSL. This allows the consumer to be tested in + isolation from the real provider, ensuring that the contract is correct + before integration. + + Args: + pacts_path: + The path where the generated pact file will be written. + + Yields: + A Pact object for use in tests. + """ + pact = Pact("aiohttp-consumer", "flask-provider").with_specification("V4") + yield pact + pact.write_file(pacts_path) + + +def test_get_user(pact: Pact) -> None: + """ + Test the GET request for a user. + + This test defines the expected interaction for a GET request for a user. It + demonstrates how to use Pact to specify the expected request and response, + and how to verify that the consumer code can handle the response correctly. + """ + response: dict[str, object] = { + "id": match.int(123), + "name": match.str("Alice"), + "created_on": match.datetime(), + } + ( + pact + .upon_receiving("A user request") + .given("the user exists", id=123, name="Alice") + .with_request("GET", "/users/123") + .will_respond_with(200) + .with_body(response, content_type="application/json") + ) + + with ( + pact.serve() as srv, + UserClient(str(srv.url)) as client, + ): + user = client.get_user(123) + assert user.name == "Alice" + + +def test_get_unknown_user(pact: Pact) -> None: + """ + Test the GET request for an unknown user. + + This test defines the expected interaction for a GET request for a user that + does not exist. It verifies that the consumer handles error responses as + expected. + """ + response = {"detail": "User not found"} + ( + pact + .upon_receiving("A request for an unknown user") + .given("the user doesn't exist", id=123) + .with_request("GET", "/users/123") + .will_respond_with(404) + .with_body(response, content_type="application/json") + ) + + with ( + pact.serve() as srv, + UserClient(str(srv.url)) as client, + pytest.raises(requests.HTTPError), + ): + client.get_user(123) + + +def test_create_user(pact: Pact) -> None: + """ + Test the POST request for creating a new user. + + This test defines the expected interaction for a POST request to create a + new user. It demonstrates how to specify the request and response, and how + to verify that the consumer can handle the provider's response. This also + shows how Pact can support multiple requests and responses within a single + test case. + """ + payload: dict[str, Any] = {"name": "Bob"} + response: dict[str, Any] = { + "id": match.int(1000), + "name": "Bob", + "created_on": match.datetime(datetime.now(tz=timezone.utc)), + } + + ( + pact + .upon_receiving("A request to create a new user") + .with_request("POST", "/users") + .with_body(payload, content_type="application/json") + .will_respond_with(201) + .with_body(response, content_type="application/json") + ) + + with pact.serve() as srv, UserClient(str(srv.url)) as client: + user = client.create_user(name="Bob") + assert user.id == 1000 + + +def test_delete_user(pact: Pact) -> None: + """ + Test the DELETE request for deleting a user. + + This test defines the expected interaction for a DELETE request to delete a + user. It demonstrates how to use Pact to specify the expected request and + response, and how to verify that the consumer code can handle the response + correctly. + """ + ( + pact + .upon_receiving("A user deletion request") + .given("the user exists", id=124, name="Bob") + .with_request("DELETE", "/users/124") + .will_respond_with(204) + ) + + with pact.serve() as srv, UserClient(str(srv.url)) as client: + client.delete_user(124) diff --git a/examples/http/requests_and_fastapi/test_provider.py b/examples/http/requests_and_fastapi/test_provider.py new file mode 100644 index 000000000..ffd5a445b --- /dev/null +++ b/examples/http/requests_and_fastapi/test_provider.py @@ -0,0 +1,228 @@ +""" +Provider contract tests using Pact. + +This module demonstrates how to test a FastAPI provider (see +[`provider.py`][examples.http.requests_and_fastapi.provider]) against a mock +consumer using Pact. The mock consumer replays the requests defined by the +consumer contract, and Pact validates that the provider responds as expected. + +These tests show how provider verification ensures that the provider remains +compatible with the consumer contract as the provider evolves. Provider state +management is handled by mocking the database and using provider state +endpoints. For more, see the [Pact Provider +Test](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-provider-pact-test) +section of the Pact documentation. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import logging +from datetime import datetime, timezone +from threading import Thread +from typing import TYPE_CHECKING, Any, Literal + +import pytest +import uvicorn + +import pact._util +from examples.http.requests_and_fastapi.provider import User, UserDb, app +from pact import Verifier + +if TYPE_CHECKING: + from pathlib import Path + from typing import TypeAlias + + ACTION_TYPE: TypeAlias = Literal["setup", "teardown"] + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="session") +def app_server() -> str: + """ + Run the FastAPI server for provider verification. + + Returns: + The base URL of the running FastAPI server. + """ + hostname = "localhost" + port = pact._util.find_free_port() # noqa: SLF001 + Thread( + target=uvicorn.run, + args=(app,), + kwargs={"host": hostname, "port": port}, + daemon=True, + ).start() + return f"http://{hostname}:{port}" + + +def test_provider(app_server: str, pacts_path: Path) -> None: + """ + Test the provider against the mock consumer contract. + + This test runs the Pact verifier against the FastAPI provider, using the + contract generated by the consumer tests. + + Provider state handlers are essential in Pact contract testing. They allow + the provider to be set up in a specific state before each interaction is + verified. For example, if a consumer expects a user to exist for a certain + request, the provider state handler ensures the database is populated + accordingly. This enables repeatable, isolated, and meaningful contract + verification, as each interaction can be tested in the correct context + without relying on global or persistent state. + + In this example, the state handlers `mock_user_exists` and + `mock_user_does_not_exist` are mapped to the states described in the + contract. They are responsible for setting up (and tearing down) the + in-memory database so that the provider can respond correctly to each + request defined by the consumer contract. + + For additional information on state handlers, see + [`Verifier.state_handler`][pact.verifier.Verifier.state_handler]. + """ + verifier = ( + Verifier("fastapi-provider") + .add_source(pacts_path) + .add_transport(url=app_server) + .state_handler( + { + "the user exists": mock_user_exists, + "the user doesn't exist": mock_user_does_not_exist, + }, + teardown=True, + ) + ) + + verifier.verify() + + +def default_mock_db() -> dict[int, User]: + """ + Standard in-memory database for provider state mocking. + + This function pre-populates a mock database with some default users. It is + used by the provider state handlers to ensure that the database is in the + correct state for each interaction. + + Returns: + A dictionary of user IDs to User objects for use in tests. + """ + return { + 1: User( + id=1, + name="Alice", + email="alice@example.com", + created_on=datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + ip_address="1.2.3.4", + hobbies=["pact testing", "programming", "qa"], + admin=False, + ), + 2: User( + id=2, + name="Bob", + email=None, # Edge case: email is None + created_on=datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc), + ip_address="", + hobbies=[], + admin=True, + ), + 10: User( + id=10, + name="Charlie", + email="charlie@example.com", + created_on=datetime(2025, 8, 8, 8, 8, 8, tzinfo=timezone.utc), + ip_address="3.4.5.6", + hobbies=[""], + admin=False, + ), + 42: User( + id=42, + name="Dana", + email="dana+test@example.com", + created_on=datetime(2022, 2, 22, 2, 22, 22, tzinfo=timezone.utc), + ip_address="255.255.255.255", + hobbies=["edge", "case", "testing"], + admin=False, + ), + } + + +def mock_user_exists( + action: Literal["setup", "teardown"], + parameters: dict[str, Any], +) -> None: + """ + Mock the provider state where a user exists. + + This handler sets up the provider so that a user with the given ID exists in + the database. Used by Pact to ensure the provider is in the correct state + for each interaction. + + Args: + action: + The action to perform, either "setup" or "teardown". + + parameters: + User information, including an `id` to guarantee presence in the + database. Additional fields may be provided to override defaults. + """ + logger.debug("mock_user_exists(%s, %r)", action, parameters) + + # We pre-populate the database with some data, and if a state requires + # some specific data, ensure the user is present. + db = default_mock_db() + user = db[uid] if (uid := parameters.get("id")) in db else next(iter(db.values())) + user = User(**{**dataclasses.asdict(user), **parameters}) + + if action == "setup": + UserDb.create(user) + return + + if action == "teardown": + with contextlib.suppress(KeyError): + UserDb.delete(user.id) + return + + msg = f"Unknown action: {action}" + raise ValueError(msg) + + +def mock_user_does_not_exist( + action: Literal["setup", "teardown"], + parameters: dict[str, Any], +) -> None: + """ + Mock the provider state where a user does not exist. + + This handler sets up the provider so that a user with the given ID does not + exist in the database. Used by Pact to ensure the provider is in the correct + state for each interaction. + + Args: + action: + The action to perform, either "setup" or "teardown". + + parameters: + User information, must contain an `id` to guarantee absence in the + database. + """ + logger.debug("mock_user_does_not_exist(%s, %r)", action, parameters) + + if "id" not in parameters: + msg = "State must contain an 'id' field to mock user non-existence" + raise ValueError(msg) + + uid = parameters["id"] + + if action == "setup": + if user := UserDb.get(uid): + UserDb.delete(user.id) + return + + if action == "teardown": + return + + msg = f"Unknown action: {action}" + raise ValueError(msg) diff --git a/examples/http/service_consumer_provider/README.md b/examples/http/service_consumer_provider/README.md new file mode 100644 index 000000000..78a9a1398 --- /dev/null +++ b/examples/http/service_consumer_provider/README.md @@ -0,0 +1,37 @@ +# Service as Consumer and Provider + +This example demonstrates a common microservice pattern where one service plays both roles in contract testing: + +- **Provider** to a frontend client (`frontend-web → user-service`) +- **Consumer** of an upstream auth service (`user-service → auth-service`) + +## Overview + +- [**Frontend Client**][examples.http.service_consumer_provider.frontend_client]: Consumer-facing client used by `frontend-web` +- [**Auth Client**][examples.http.service_consumer_provider.auth_client]: Upstream client used by `user-service` to call `auth-service` +- [**User Service**][examples.http.service_consumer_provider.user_service]: FastAPI app under test (the service in the middle) +- [**Frontend Consumer Tests**][examples.http.service_consumer_provider.test_consumer_frontend]: Defines `frontend-web`'s expectations of `user-service` +- [**Auth Consumer Tests**][examples.http.service_consumer_provider.test_consumer_auth]: Defines `user-service`'s expectations of `auth-service` +- [**Provider Verification**][examples.http.service_consumer_provider.test_provider]: Verifies `user-service` against the frontend pact + +Use the links above to view detailed documentation within each file. + +## What This Example Demonstrates + +- One service owning two separate contracts in opposite directions +- Consumer tests for each dependency boundary +- Provider verification with state handlers that model upstream `auth-service` behaviour without needing it to run +- A `Protocol`-based seam that allows the real FastAPI application to run during verification while the upstream dependency is replaced in-process + +## Running the Example + +```console +uv run --group test pytest +``` + +## Related Documentation + +- [Pact Documentation](https://docs.pact.io/) +- [Provider States](https://docs.pact.io/getting_started/provider_states) +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [pytest Documentation](https://docs.pytest.org/) diff --git a/examples/http/service_consumer_provider/__init__.py b/examples/http/service_consumer_provider/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/examples/http/service_consumer_provider/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/examples/http/service_consumer_provider/auth_client.py b/examples/http/service_consumer_provider/auth_client.py new file mode 100644 index 000000000..b95b60463 --- /dev/null +++ b/examples/http/service_consumer_provider/auth_client.py @@ -0,0 +1,76 @@ +""" +HTTP client used by `user-service` to call `auth-service`. + +This module is intentionally free of any Pact dependency, it is production code. +The Pact dependency only appears in +[`test_consumer_auth`][examples.http.service_consumer_provider.test_consumer_auth], +which exercises this client against a Pact mock server to define the contract +between [`user_service`][examples.http.service_consumer_provider.user_service] +(consumer) and `auth-service` (provider). + +This also demonstrates the consumer-driven philosophy: the client only requests +and parses the fields it actually needs (`valid`), even though `auth-service` +may return additional information in its response. +""" + +from __future__ import annotations + +import requests + + +class AuthClient: + """ + HTTP client for credential validation against `auth-service`. + + This client is used by + [`user_service`][examples.http.service_consumer_provider.user_service] to + verify user credentials before creating accounts. It satisfies the + [`CredentialsVerifier`][examples.http.service_consumer_provider.user_service.CredentialsVerifier] + protocol and is the real implementation that would run in production. + + The matching Pact consumer tests live in + [`test_consumer_auth`][examples.http.service_consumer_provider.test_consumer_auth]. + """ + + def __init__(self, base_url: str) -> None: + """ + Initialise the auth client. + + Args: + base_url: + Base URL of `auth-service`, e.g. `http://auth-service`. Trailing + slashes are stripped automatically. + """ + self._base_url = base_url.rstrip("/") + self._session = requests.Session() + + def validate_credentials(self, username: str, password: str) -> bool: + """ + Validate credentials against `auth-service`. + + Sends a `POST /auth/validate` request with the supplied credentials and + returns whether `auth-service` considers them valid. This is the only + field the client reads from the response: an example of how + consumer-driven contracts focus on what the consumer *actually uses*. + + Args: + username: + Username to validate. + + password: + Password to validate. + + Returns: + `True` when credentials are valid; otherwise `False`. + + Raises: + requests.HTTPError: + If `auth-service` responds with a non-2xx status. + """ + response = self._session.post( + f"{self._base_url}/auth/validate", + json={"username": username, "password": password}, + ) + response.raise_for_status() + body = response.json() + return bool(body.get("valid", False)) diff --git a/examples/http/service_consumer_provider/conftest.py b/examples/http/service_consumer_provider/conftest.py new file mode 100644 index 000000000..0182939b6 --- /dev/null +++ b/examples/http/service_consumer_provider/conftest.py @@ -0,0 +1,37 @@ +""" +Shared PyTest configuration for the service-as-consumer/provider example. +""" + +from __future__ import annotations + +import contextlib +from pathlib import Path + +import pytest + +import pact_ffi + +EXAMPLE_DIR = Path(__file__).parent.resolve() + + +@pytest.fixture(scope="session") +def pacts_path() -> Path: + """ + Fixture for the Pact directory. + + Returns: + Path to the directory where Pact contract files are written. + """ + return EXAMPLE_DIR / "pacts" + + +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + # If the logger is already configured (e.g., when running alongside other + # examples), log_to_stderr raises RuntimeError. We suppress it here so + # that the first configuration wins. + with contextlib.suppress(RuntimeError): + pact_ffi.log_to_stderr("INFO") diff --git a/examples/http/service_consumer_provider/frontend_client.py b/examples/http/service_consumer_provider/frontend_client.py new file mode 100644 index 000000000..093517ab7 --- /dev/null +++ b/examples/http/service_consumer_provider/frontend_client.py @@ -0,0 +1,98 @@ +""" +HTTP client representing `frontend-web` calling `user-service`. + +This module is intentionally free of any Pact dependency; it is production code. +The Pact dependency only appears in +[`test_consumer_frontend`][examples.http.service_consumer_provider.test_consumer_frontend], +which exercises this client against a Pact mock server to define the contract +between `frontend-web` (consumer) and +[`user_service`][examples.http.service_consumer_provider.user_service] +(provider). + +Notice that the +[`Account`][examples.http.service_consumer_provider.frontend_client.Account] +dataclass only captures the fields `frontend-web` cares about (`id`, `username`, +`status`). This is a deliberate illustration of how consumer-driven contracts +differ from testing an OpenAPI specification: the contract describes what *this +consumer* uses, not everything the provider exposes. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import requests + + +@dataclass +class Account: + """ + Minimal account model as seen by `frontend-web`. + + This class intentionally reflects only the fields the frontend consumer + needs. It may differ from the internal representation in + [`user_service`][examples.http.service_consumer_provider.user_service], + which stores additional state. This asymmetry is expected and is a key + feature of consumer-driven contract testing. + """ + + id: int + username: str + status: str + + +class FrontendClient: + """ + HTTP client used by `frontend-web` to call `user-service`. + + This client is the consumer under test in + [`test_consumer_frontend`][examples.http.service_consumer_provider.test_consumer_frontend]. + Keeping it free of Pact dependencies means it can be used as-is in + production while the tests handle all contract verification. + """ + + def __init__(self, base_url: str) -> None: + """ + Initialise the frontend client. + + Args: + base_url: + Base URL of `user-service`, e.g. `http://user-service`. Trailing + slashes are stripped automatically. + """ + self._base_url = base_url.rstrip("/") + self._session = requests.Session() + + def create_account(self, username: str, password: str) -> Account: + """ + Create an account through `user-service`. + + Sends a `POST /accounts` request and deserialises the response into an + [`Account`][examples.http.service_consumer_provider.frontend_client.Account]. + Only the `id`, `username`, and `status` fields are read from the + response, and any additional fields returned by the provider are + ignored. + + Args: + username: + Desired username for the new account. + + password: + Password forwarded to `user-service`, which validates it against + `auth-service` before creating the account. + + Returns: + Account data returned by `user-service`. + + Raises: + requests.HTTPError: + If `user-service` responds with a non-2xx status (e.g., `401` + when credentials are rejected). + """ + response = self._session.post( + f"{self._base_url}/accounts", + json={"username": username, "password": password}, + ) + response.raise_for_status() + body = response.json() + return Account(id=body["id"], username=body["username"], status=body["status"]) diff --git a/examples/http/service_consumer_provider/pyproject.toml b/examples/http/service_consumer_provider/pyproject.toml new file mode 100644 index 000000000..48fcbe198 --- /dev/null +++ b/examples/http/service_consumer_provider/pyproject.toml @@ -0,0 +1,27 @@ +#:schema https://www.schemastore.org/pyproject.json + +[project] +name = "example-service-consumer-provider" + +description = "Example of a service acting as both a Pact consumer and provider" + +version = "1.0.0" +requires-python = ">=3.10" +dependencies = ["fastapi~=0.0", "requests~=2.0", "typing-extensions~=4.0"] + +[dependency-groups] +test = ["pact-python", "pytest~=9.0", "uvicorn~=0.29"] + +[tool] + [tool.pytest] + addopts = ["--import-mode=importlib"] + + log_date_format = "%H:%M:%S" + log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" + log_level = "NOTSET" + + [tool.ruff] + extend = "../../../pyproject.toml" + + [tool.uv.sources] + pact-python = { path = "../../../" } diff --git a/examples/http/service_consumer_provider/test_consumer_auth.py b/examples/http/service_consumer_provider/test_consumer_auth.py new file mode 100644 index 000000000..8bfdd6bbf --- /dev/null +++ b/examples/http/service_consumer_provider/test_consumer_auth.py @@ -0,0 +1,128 @@ +""" +Consumer contract tests for `user-service` → `auth-service`. + +This module defines the contract that +[`user_service`][examples.http.service_consumer_provider.user_service] (acting +as a *consumer*) expects `auth-service` (the *provider*) to honour. When these +tests run, Pact starts a mock server in place of `auth-service` and verifies +that +[`AuthClient`][examples.http.service_consumer_provider.auth_client.AuthClient] +makes exactly the requests specified here and can handle the responses. + +The generated Pact file (written to the `pacts/` directory) would normally be +published to a Pact Broker so that the `auth-service` team can run provider +verification against it. In this self-contained example the file is consumed +locally by the provider verification tests. + +For background on consumer testing, see the [Pact consumer test +guide](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-consumer-pact-test). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from examples.http.service_consumer_provider.auth_client import AuthClient +from pact import Pact + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + +@pytest.fixture +def pact(pacts_path: Path) -> Generator[Pact, None, None]: + """ + Pact fixture for `user-service` as consumer of `auth-service`. + + Creates a V4 Pact between `user-service` (consumer) and `auth-service` + (provider). Each test in this module can define one or more expected + interactions on the returned `Pact` object; the mock provider will validate + that the consumer code sends exactly those requests and handles the + responses correctly. After the test, the contract is written to *pacts_path* + for use in provider verification. + + Args: + pacts_path: + Directory where the generated Pact file is written. + + Yields: + Pact configured for `user-service` → `auth-service`. + """ + pact = Pact("user-service", "auth-service").with_specification("V4") + yield pact + pact.write_file(pacts_path) + + +@pytest.mark.parametrize( + ("password", "expected_valid"), + [ + pytest.param("correct-horse-battery-staple", True, id="valid"), + pytest.param("wrong-password", False, id="invalid"), + ], +) +def test_validate_credentials( + pact: Pact, + password: str, + *, + expected_valid: bool, +) -> None: + """ + Verify the `AuthClient` contract for both valid and invalid credentials. + + This parametrised test covers two interactions in a single contract: + + - **Valid credentials**: `auth-service` responds `{"valid": true}`, and + [`AuthClient.validate_credentials`][examples.http.service_consumer_provider.auth_client.AuthClient.validate_credentials] + returns `True`. + - **Invalid credentials**: `auth-service` responds `{"valid": false}`, and + `AuthClient.validate_credentials` returns `False`. + + Both cases map to the same endpoint (`POST /auth/validate`) but are modelled + as separate Pact interactions with different provider states. This ensures + that `auth-service` must support both outcomes, not just the happy path. + + Args: + pact: + Pact fixture for `user-service` → `auth-service`. + + password: + Password sent to `auth-service`; determines which provider state + (and therefore which mock response) is used. + + expected_valid: + The validation result the consumer expects to receive and act on. + """ + state = ( + "user credentials are valid" + if expected_valid + else "user credentials are invalid" + ) + + ( + pact + .upon_receiving(f"Credential validation for {state}") + .given(state) + .with_request("POST", "/auth/validate") + .with_body( + { + "username": "alice", + "password": password, + }, + content_type="application/json", + ) + .will_respond_with(200) + .with_body( + { + "valid": expected_valid, + "subject": "alice", + }, + content_type="application/json", + ) + ) + + with pact.serve() as srv: + client = AuthClient(str(srv.url)) + assert client.validate_credentials("alice", password) is expected_valid diff --git a/examples/http/service_consumer_provider/test_consumer_frontend.py b/examples/http/service_consumer_provider/test_consumer_frontend.py new file mode 100644 index 000000000..e7ded130d --- /dev/null +++ b/examples/http/service_consumer_provider/test_consumer_frontend.py @@ -0,0 +1,144 @@ +""" +Consumer contract tests for `frontend-web` → `user-service`. + +This module defines the contract that `frontend-web` (acting as a *consumer*) +expects [`user_service`][examples.http.service_consumer_provider.user_service] +(the *provider*) to honour. When these tests run, Pact starts a mock server in +place of `user-service` and verifies that +[`FrontendClient`][examples.http.service_consumer_provider.frontend_client.FrontendClient] +makes exactly the requests specified here and can handle the responses. + +The generated Pact file is used by the provider verification test in +[`test_provider`][examples.http.service_consumer_provider.test_provider], which +runs the real `user-service` and replays these interactions against it. + +For background on consumer testing, see the [Pact consumer test +guide](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-consumer-pact-test). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import requests + +from examples.http.service_consumer_provider.frontend_client import FrontendClient +from pact import Pact, match + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + +@pytest.fixture +def pact(pacts_path: Path) -> Generator[Pact, None, None]: + """ + Pact fixture for `frontend-web` as consumer of `user-service`. + + Creates a V4 Pact between `frontend-web` (consumer) and `user-service` + (provider). Each test in this module defines one or more expected + interactions on the returned `Pact` object; Pact validates that + `FrontendClient` sends exactly those requests and handles the responses + correctly. After the test, the contract is written to *pacts_path* for + provider verification. + + Args: + pacts_path: + Directory where the generated Pact file is written. + + Yields: + Pact configured for `frontend-web` → `user-service`. + """ + pact = Pact("frontend-web", "user-service").with_specification("V4") + yield pact + pact.write_file(pacts_path) + + +def test_create_account_success(pact: Pact) -> None: + """ + Verify `FrontendClient` behaviour when credentials are valid. + + This test defines the happy-path interaction: `frontend-web` POSTs an + account creation request with valid credentials, and `user-service` responds + with `201 Created` and the new account details. + + Note the use of `match.int(1001)` for the `id` field. This tells Pact to + verify that the field *type* is an integer, not that the value is exactly + `1001`. This makes the contract resilient to auto-incremented IDs while + still ensuring the consumer receives a numeric identifier it can work with. + + The provider state `"auth accepts credentials"` signals to the provider + verification test (see + [`test_provider`][examples.http.service_consumer_provider.test_provider]) + that it must configure a stub `auth-service` that accepts the supplied + credentials. + """ + ( + pact + .upon_receiving("A request to create an account") + .given("auth accepts credentials") + .with_request("POST", "/accounts") + .with_body( + { + "username": "alice", + "password": "correct-horse-battery-staple", + }, + content_type="application/json", + ) + .will_respond_with(201) + .with_body( + { + "id": match.int(1001), + "username": "alice", + "status": "created", + }, + content_type="application/json", + ) + ) + + with pact.serve() as srv: + client = FrontendClient(str(srv.url)) + account = client.create_account("alice", "correct-horse-battery-staple") + assert account.id == 1001 + assert account.username == "alice" + assert account.status == "created" + + +def test_create_account_invalid_credentials(pact: Pact) -> None: + """ + Verify `FrontendClient` behaviour when credentials are invalid. + + This test defines the failure-path interaction: `frontend-web` POSTs an + account creation request with invalid credentials, and `user-service` + responds with `401 Unauthorized`. The consumer is expected to propagate the + error as a `requests.HTTPError`. + + Testing error paths in Pact contracts is important: it ensures the provider + contract covers not just the happy path but also the error responses that + consumers must handle gracefully. + + The provider state `"auth rejects credentials"` signals to the provider + verification test that the stub `auth-service` must reject the supplied + credentials. + """ + ( + pact + .upon_receiving("A request with invalid credentials") + .given("auth rejects credentials") + .with_request("POST", "/accounts") + .with_body( + { + "username": "alice", + "password": "wrong-password", + }, + content_type="application/json", + ) + .will_respond_with(401) + .with_body({"detail": "Invalid credentials"}, content_type="application/json") + ) + + with pact.serve() as srv: + client = FrontendClient(str(srv.url)) + with pytest.raises(requests.HTTPError): + client.create_account("alice", "wrong-password") diff --git a/examples/http/service_consumer_provider/test_provider.py b/examples/http/service_consumer_provider/test_provider.py new file mode 100644 index 000000000..cea79b7cc --- /dev/null +++ b/examples/http/service_consumer_provider/test_provider.py @@ -0,0 +1,218 @@ +""" +Provider verification for `user-service` against the `frontend-web` contract. + +This module runs the Pact verifier against the real +[`user_service`][examples.http.service_consumer_provider.user_service] FastAPI +application to confirm that it honours the contract defined by the consumer +tests in +[`test_consumer_frontend`][examples.http.service_consumer_provider.test_consumer_frontend]. + +## How provider verification works + +Pact replays each interaction from the contract file against the running +`user-service`. Before each interaction it calls the appropriate *provider state +handler* to put the service in the right state. For example, the interaction `"A +request to create an account"` requires the state `"auth accepts credentials"`, +so Pact calls `set_auth_accepts` first, which installs a +[`StubAuthVerifier`][examples.http.service_consumer_provider.test_provider.StubAuthVerifier] +that always returns `True`. + +This lets the entire `user-service` run for real while still being independent +of a live `auth-service`. The +[`CredentialsVerifier`][examples.http.service_consumer_provider.user_service.CredentialsVerifier] +protocol in `user_service.py` is the seam that makes this possible. + +For more background, see the [Pact provider test +guide](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-provider-pact-test) +and the documentation for +[`Verifier.state_handler`][pact.verifier.Verifier.state_handler]. +""" + +from __future__ import annotations + +import contextlib +import time +from threading import Thread +from typing import TYPE_CHECKING + +import pytest +import requests +import uvicorn + +import pact._util +from examples.http.service_consumer_provider.user_service import ( + app, + reset_state, + set_auth_verifier, +) +from pact import Verifier + +if TYPE_CHECKING: + from pathlib import Path + + +class StubAuthVerifier: + """ + In-process stub for `auth-service`, used by provider state handlers. + + Rather than starting a real `auth-service` during provider verification, the + tests replace the + [`AuthClient`][examples.http.service_consumer_provider.auth_client.AuthClient] + with this stub via + [`set_auth_verifier`][examples.http.service_consumer_provider.user_service.set_auth_verifier]. + The stub satisfies the + [`CredentialsVerifier`][examples.http.service_consumer_provider.user_service.CredentialsVerifier] + protocol and returns a fixed result for every call, making provider states + simple and deterministic. + + The real `AuthClient` behaviour is separately verified by the consumer tests + in + [`test_consumer_auth`][examples.http.service_consumer_provider.test_consumer_auth]. + """ + + def __init__(self, *, valid: bool) -> None: + """ + Create a stub verifier. + + Args: + valid: + Result to return for all validations. + """ + self._valid = valid + + def validate_credentials(self, username: str, password: str) -> bool: + """ + Validate credentials. + + Args: + username: + Ignored in this stub. + + password: + Ignored in this stub. + + Returns: + The configured validation result. + """ + del username, password + return self._valid + + +@pytest.fixture(scope="session") +def app_server() -> str: + """ + Start the `user-service` FastAPI application for provider verification. + + Launches the application in a daemon thread so it is torn down when the test + process exits. The fixture polls the `/docs` endpoint until the server is + accepting connections (up to 5 seconds), which avoids race conditions when + the verifier immediately begins replaying interactions. + + Returns: + Base URL of the running `user-service`, e.g. `http://localhost:54321`. + """ + hostname = "localhost" + port = pact._util.find_free_port() # noqa: SLF001 + Thread( + target=uvicorn.run, + args=(app,), + kwargs={"host": hostname, "port": port}, + daemon=True, + ).start() + + base_url = f"http://{hostname}:{port}" + for _ in range(50): + with contextlib.suppress(requests.RequestException): + response = requests.get(f"{base_url}/docs", timeout=0.2) + if response.status_code < 500: + return base_url + time.sleep(0.1) + + msg = f"user-service did not start at {base_url}" + raise RuntimeError(msg) + + +def set_auth_accepts(parameters: dict[str, object] | None = None) -> None: + """ + Provider state: `auth-service` accepts credentials. + + Configures `user-service` so that any credential validation attempt + succeeds. This models the scenario where the upstream `auth-service` + considers the supplied credentials valid, allowing account creation to + proceed normally. + + Called by the Pact verifier before interactions that carry the provider + state `"auth accepts credentials"`. + + Args: + parameters: + Optional Pact state parameters. Not used by this state. + """ + del parameters + reset_state() + set_auth_verifier(StubAuthVerifier(valid=True)) + + +def set_auth_rejects(parameters: dict[str, object] | None = None) -> None: + """ + Provider state: `auth-service` rejects credentials. + + Configures `user-service` so that any credential validation attempt fails. + This models the scenario where the upstream `auth-service` considers the + supplied credentials invalid, causing `user-service` to return `401 + Unauthorized`. + + Called by the Pact verifier before interactions that carry the provider + state `"auth rejects credentials"`. + + Args: + parameters: + Optional Pact state parameters. Not used by this state. + """ + del parameters + reset_state() + set_auth_verifier(StubAuthVerifier(valid=False)) + + +def test_provider(app_server: str, pacts_path: Path) -> None: + """ + Verify `user-service` against the `frontend-web` consumer contract. + + This test uses the Pact verifier to replay each interaction from the + contract generated by + [`test_consumer_frontend`][examples.http.service_consumer_provider.test_consumer_frontend] + against the running `user-service`. Before each interaction, the verifier + calls the appropriate provider state handler to configure the service. After + all interactions have been replayed, Pact reports any mismatches. + + Provider state handlers are the mechanism Pact uses to decouple verification + from infrastructure: instead of wiring up a real `auth-service`, each state + handler installs a `StubAuthVerifier` that returns a predetermined result. + This makes verification fast, deterministic, and free of external + dependencies. + + Note that `teardown=False` is set on the state handler because the handlers + use `reset_state()` at the *start* of each setup call. Explicit teardown is + unnecessary when the next setup always resets to a clean slate. + + Args: + app_server: + Base URL of the running `user-service` provider. + + pacts_path: + Directory containing the Pact contract files to verify against. + """ + verifier = ( + Verifier("user-service") + .add_source(pacts_path) + .add_transport(url=app_server) + .state_handler( + { + "auth accepts credentials": set_auth_accepts, + "auth rejects credentials": set_auth_rejects, + }, + teardown=False, + ) + ) + + verifier.verify() diff --git a/examples/http/service_consumer_provider/user_service.py b/examples/http/service_consumer_provider/user_service.py new file mode 100644 index 000000000..8d144fb43 --- /dev/null +++ b/examples/http/service_consumer_provider/user_service.py @@ -0,0 +1,195 @@ +""" +FastAPI service acting as both a Pact consumer and a Pact provider. + +This module is the centrepiece of the example. `user-service` sits in the middle +of a two-hop request path: + +```text +frontend-web → user-service → auth-service +``` + +This means it plays two Pact roles simultaneously: + +- **Provider** of the `POST /accounts` endpoint consumed by `frontend-web`. The + provider verification test lives in + [`test_provider`][examples.http.service_consumer_provider.test_provider]. + +- **Consumer** of `auth-service`'s `POST /auth/validate` endpoint. The consumer + contract test lives in + [`test_consumer_auth`][examples.http.service_consumer_provider.test_consumer_auth]. + +## Testability design + +Provider verification requires the service to be started as a real HTTP server. +To avoid needing a real `auth-service` during those tests, this module uses the +[`CredentialsVerifier`][examples.http.service_consumer_provider.user_service.CredentialsVerifier] +protocol as a seam. In production the seam is filled by +[`AuthClient`][examples.http.service_consumer_provider.auth_client.AuthClient]; +in tests it is replaced with a +[`StubAuthVerifier`][examples.http.service_consumer_provider.test_provider.StubAuthVerifier] +via +[`set_auth_verifier`][examples.http.service_consumer_provider.user_service.set_auth_verifier]. + +This avoids mocking at the HTTP level. The real FastAPI application runs, and +only the collaborator that calls `auth-service` is swapped out. + +## Module-level state + +[`SERVICE_STATE`][examples.http.service_consumer_provider.user_service.SERVICE_STATE] +and +[`ACCOUNT_STORE`][examples.http.service_consumer_provider.user_service.ACCOUNT_STORE] +are intentional module-level globals. Because provider state handlers in Pact +run in the same process as the application, these globals are the simplest way +for the test harness to reconfigure the service between interactions without an +additional HTTP endpoint. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from fastapi import FastAPI, HTTPException, status +from pydantic import BaseModel + +from examples.http.service_consumer_provider.auth_client import AuthClient + + +class CredentialsVerifier(Protocol): + """ + Behaviour required for credential verification. + """ + + def validate_credentials(self, username: str, password: str) -> bool: + """ + Validate credentials. + """ + + +@dataclass +class UserAccount: + """ + Stored account record. + """ + + id: int + username: str + + +class InMemoryAccountStore: + """ + Small in-memory store for example purposes. + """ + + def __init__(self) -> None: + """ + Initialise the in-memory store. + """ + self._next_id = 1 + self._accounts: dict[int, UserAccount] = {} + + def create(self, username: str) -> UserAccount: + """ + Create and store a new account. + + Args: + username: + Username for the new account. + + Returns: + The created account. + """ + account = UserAccount(id=self._next_id, username=username) + self._accounts[account.id] = account + self._next_id += 1 + return account + + def reset(self) -> None: + """ + Reset all stored accounts. + """ + self._next_id = 1 + self._accounts.clear() + + +class CreateAccountRequest(BaseModel): + """ + Request payload used by frontend-web. + """ + + username: str + password: str + + +class CreateAccountResponse(BaseModel): + """ + Response payload returned to frontend-web. + """ + + id: int + username: str + status: str = "created" + + +ACCOUNT_STORE = InMemoryAccountStore() + + +class _ServiceState: + """ + Mutable state used by provider-state handlers in tests. + """ + + def __init__(self) -> None: + """ + Initialise default collaborators. + """ + self.auth_verifier: CredentialsVerifier = AuthClient("http://auth-service") + + +SERVICE_STATE = _ServiceState() + +app = FastAPI() + + +def set_auth_verifier(verifier: CredentialsVerifier) -> None: + """ + Replace the auth verifier implementation. + + Args: + verifier: + New verifier implementation. + """ + SERVICE_STATE.auth_verifier = verifier + + +def reset_state() -> None: + """ + Reset internal provider state. + """ + ACCOUNT_STORE.reset() + + +@app.post("/accounts", status_code=status.HTTP_201_CREATED) +async def create_account(payload: CreateAccountRequest) -> CreateAccountResponse: + """ + Create an account after validating credentials with auth-service. + + Args: + payload: + Account request payload. + + Returns: + Created account response. + + Raises: + HTTPException: + If credentials are invalid. + """ + if not SERVICE_STATE.auth_verifier.validate_credentials( + payload.username, + payload.password, + ): + raise HTTPException(status_code=401, detail="Invalid credentials") + + account = ACCOUNT_STORE.create(payload.username) + return CreateAccountResponse(id=account.id, username=account.username) diff --git a/examples/http/xml_example/README.md b/examples/http/xml_example/README.md new file mode 100644 index 000000000..894d2c329 --- /dev/null +++ b/examples/http/xml_example/README.md @@ -0,0 +1,143 @@ +# Example: requests Client and FastAPI Provider with XML Contract Testing + +This example demonstrates contract testing between a synchronous +[`requests`](https://docs.python-requests.org/en/latest/)-based client +(consumer) and a [FastAPI](https://fastapi.tiangolo.com/) web server (provider) +where the payload format is XML rather than JSON. It is designed to be +pedagogical, highlighting both the standard Pact contract testing workflow and +the specific constraints that arise when working with XML. + +## Overview + +- [**Consumer**][examples.http.xml_example.consumer]: Synchronous HTTP client + using requests, parsing XML with `xml.etree.ElementTree` +- [**Provider**][examples.http.xml_example.provider]: FastAPI web server + returning XML responses +- [**Consumer Tests**][examples.http.xml_example.test_consumer]: Contract + definition and consumer testing with Pact +- [**Provider Tests**][examples.http.xml_example.test_provider]: Provider + verification against contracts + +Use the above links to view additional documentation within. + +## What This Example Demonstrates + +### Consumer Side + +- Synchronous HTTP client implementation with requests +- Parsing XML responses using the standard library + [`xml.etree.ElementTree`][xml.etree.ElementTree] module +- Consumer contract testing with Pact mock servers +- Setting request headers (e.g., `Accept: application/xml`) using + `.with_header()` as a separate chain step + +### Provider Side + +- FastAPI web server returning `application/xml` responses built with + `xml.etree.ElementTree` +- Provider verification against consumer contracts +- Provider state setup and teardown for isolated, repeatable verification + +### Testing Patterns + +- Independent consumer and provider testing +- Contract-driven development workflow +- Using the [`pact.xml`][pact.xml] builder to apply Pact matchers to XML + bodies +- How matcher-based contracts are more flexible than exact XML string matching + +## XML Matchers + +Unlike JSON, XML bodies cannot be expressed as a plain Python dict of +field-matcher pairs. Instead, use the [`pact.xml`][pact.xml] builder, which +constructs the body description from nested +[`xml.element()`][pact.xml.element] calls: + +```python +from pact import match, xml + +response = xml.body( + xml.element("user", + xml.element("id", match.int(123)), + xml.element("name", match.str("Alice")), + ) +) +``` + +Pass the result to `.with_body()` with `content_type="application/xml"`. The +Pact FFI detects that the body is JSON, generates the example XML, and +registers matching rules for each annotated element. The resulting contract +will match _any_ XML response where `` contains an integer and `` +contains a string and not just the specific example values. + +For attribute matchers, pass matcher objects via the `attrs` keyword argument: + +```python +xml.element("user", attrs={"id": match.int(1), "type": "activity"}) +``` + +For repeating elements, chain [`.each()`][pact.xml.XmlElement.each] to add a +`type` matching rule: + +```python +( + xml.element( + "items", + xml.element("item", xml.element("id", match.int(1))).each(min=1, examples=2), + ) +) +``` + +For JSON-based examples using the same matchers, see +[`requests_and_fastapi/`](../requests_and_fastapi/). + +## Prerequisites + +- Python 3.10 or higher +- A dependency manager ([uv](https://docs.astral.sh/uv/) recommended, + [pip](https://pip.pypa.io/en/stable/) also works) + +## Running the Example + +### Using uv (Recommended) + +We recommend using [uv](https://docs.astral.sh/uv/) to manage the virtual +environment and dependencies. The following command will automatically set up +the virtual environment, install dependencies, and execute the tests: + +```console +uv run --group test pytest +``` + +### Using pip + +If using the [`venv`][venv] module, the required steps are: + +1. Create and activate a virtual environment: + + ```console + python -m venv .venv + source .venv/bin/activate # On macOS/Linux + .venv\Scripts\activate # On Windows + ``` + +2. Install dependencies: + + ```console + pip install -U pip # Pip 25.1 is required + pip install --group test -e . + ``` + +3. Run tests: + + ```console + pytest + ``` + +## Related Documentation + +- [Pact Documentation](https://docs.pact.io/) +- [requests Documentation](https://docs.python-requests.org/) +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [pytest Documentation](https://docs.pytest.org/) +- [xml.etree.ElementTree Documentation](https://docs.python.org/3/library/xml.etree.elementtree.html) diff --git a/examples/http/xml_example/__init__.py b/examples/http/xml_example/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/examples/http/xml_example/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/examples/http/xml_example/conftest.py b/examples/http/xml_example/conftest.py new file mode 100644 index 000000000..acb15b98f --- /dev/null +++ b/examples/http/xml_example/conftest.py @@ -0,0 +1,33 @@ +""" +Shared pytest configuration for the XML example. + +Defines the `pacts_path` fixture used by both consumer and provider tests to +locate the directory where generated Pact contract files are stored. +""" + +from __future__ import annotations + +import contextlib +from pathlib import Path + +import pytest + +import pact_ffi + +EXAMPLE_DIR = Path(__file__).parent.resolve() + + +@pytest.fixture(scope="session") +def pacts_path() -> Path: + """Fixture providing the path to the generated Pact contract files.""" + return EXAMPLE_DIR / "pacts" + + +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + # If the logger is already configured, this will raise a RuntimeError. + with contextlib.suppress(RuntimeError): + pact_ffi.log_to_stderr("INFO") diff --git a/examples/http/xml_example/consumer.py b/examples/http/xml_example/consumer.py new file mode 100644 index 000000000..d4230f34e --- /dev/null +++ b/examples/http/xml_example/consumer.py @@ -0,0 +1,148 @@ +""" +Requests XML consumer example. + +This module defines a simple +[consumer](https://docs.pact.io/getting_started/terminology#service-consumer) +using the synchronous [`requests`][requests] library which will be tested with +Pact in the [consumer test][examples.http.xml_example.test_consumer]. As Pact +is a consumer-driven framework, the consumer defines the interactions which the +provider must then satisfy. + +The consumer sends requests expecting XML responses and parses them using the +standard library [`xml.etree.ElementTree`][xml.etree.ElementTree] module. + +This also showcases how Pact tests differ from merely testing adherence to an +OpenAPI specification. The Pact tests are more concerned with the practical use +of the API, rather than the formally defined specification. So you will see +below that the `User` class includes only the fields this consumer actually +needs, `id` and `name`, even though the provider could return additional fields +in its XML payload. + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +import logging +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import requests + +if TYPE_CHECKING: + from types import TracebackType + + from typing_extensions import Self + +logger = logging.getLogger(__name__) + + +@dataclass() +class User: + """ + Represents a user as seen by the consumer. + + This class is intentionally minimal, including only the fields the consumer + actually uses. It may differ from the [provider's user + model][examples.http.xml_example.provider.User], which could expose + additional fields. This demonstrates the consumer-driven nature of contract + testing: the consumer defines what it needs, not what the provider exposes. + """ + + id: int + name: str + + def __post_init__(self) -> None: + """ + Validate the user data after initialisation. + + Raises: + ValueError: If the name is empty or the ID is not a positive integer. + """ + if not self.name: + msg = "User must have a name" + raise ValueError(msg) + if self.id <= 0: + msg = "User ID must be a positive integer" + raise ValueError(msg) + + +class UserClient: + """ + HTTP client for interacting with a user provider service via XML. + """ + + def __init__(self, hostname: str) -> None: + """ + Initialise the user client. + + Args: + hostname: + The base URL of the provider (must include scheme, e.g., + `http://`). + + Raises: + ValueError: + If the hostname does not start with 'http://' or `https://`. + """ + if not hostname.startswith(("http://", "https://")): + msg = "Invalid base URI" + raise ValueError(msg) + self._hostname = hostname + self._session = requests.Session() + + def __enter__(self) -> Self: + """ + Begin the context for the client. + """ + self._session.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """ + Exit the context for the client. + """ + self._session.__exit__(exc_type, exc_val, exc_tb) + + def get_user(self, user_id: int) -> User: + """ + Fetch a user by ID from the provider, expecting an XML response. + + Args: + user_id: + The ID of the user to fetch. + + Returns: + A `User` instance parsed from the XML response. + + Raises: + requests.HTTPError: + If the server returns a non-2xx response. + """ + logger.debug("Fetching user %s", user_id) + response = self._session.get( + f"{self._hostname}/users/{user_id}", + headers={"Accept": "application/xml"}, + ) + response.raise_for_status() + # S314: xml.etree.ElementTree is safe here because the XML comes from + # a trusted provider over a controlled HTTP connection, not from + # arbitrary user input. + root = ET.fromstring(response.text) # noqa: S314 + id_text = root.findtext("id") + name_text = root.findtext("name") + if id_text is None or name_text is None: + msg = "Provider response missing required XML element 'id' or 'name'" + raise ValueError(msg) + return User( + id=int(id_text), + name=name_text, + ) diff --git a/examples/http/xml_example/provider.py b/examples/http/xml_example/provider.py new file mode 100644 index 000000000..e777bfa71 --- /dev/null +++ b/examples/http/xml_example/provider.py @@ -0,0 +1,144 @@ +""" +FastAPI XML provider example. + +This module defines a simple +[provider](https://docs.pact.io/getting_started/terminology#service-provider) +implemented with [`fastapi`](https://fastapi.tiangolo.com/) which will be tested +with Pact in the [provider test][examples.http.xml_example.test_provider]. As +Pact is a consumer-driven framework, the consumer defines the contract which the +provider must then satisfy. + +The provider receives requests from the consumer and returns XML responses built +using the standard library [`xml.etree.ElementTree`][xml.etree.ElementTree] +module. Serialisation is handled manually rather than via FastAPI's built-in +JSON serialisation, since FastAPI does not natively support XML response bodies. + +This also showcases how Pact tests differ from merely testing adherence to an +OpenAPI specification. The Pact tests are concerned with the practical use of +the API from the consumer's perspective. The `User` model here could contain +additional fields in a real application; if the provider later adds or removes +fields, Pact's consumer-driven testing will verify that the consumer remains +compatible with those changes. + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the provider is not concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +import logging +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from typing import ClassVar + +from fastapi import FastAPI, HTTPException, status +from fastapi.responses import Response + +logger = logging.getLogger(__name__) + + +@dataclass() +class User: + """ + Represents a user in the provider system. + + This class uses a plain dataclass rather than Pydantic to keep the focus + on the XML serialisation pattern. In a real FastAPI application you would + typically use a Pydantic model to benefit from automatic validation and + JSON serialisation; see the + [`requests_and_fastapi`][examples.http.requests_and_fastapi.provider.User] + example for that approach. + + The provider's model may contain more fields than any single consumer + requires. This demonstrates how provider-side data can be richer than what + appears in the consumer contract. + """ + + id: int + name: str + + +class UserDb: + """ + A simple in-memory user database abstraction for demonstration purposes. + + This class simulates a user database using a class-level dictionary. In a + real application this would interface with a persistent database or external + service. For testing, the state handlers in the [provider + test][examples.http.xml_example.test_provider] populate and clean up this + database directly, ensuring each contract interaction runs against a + predictable state. + """ + + _db: ClassVar[dict[int, User]] = {} + + @classmethod + def create(cls, user: User) -> None: + """ + Add a new user to the database. + + Args: + user: The User instance to add. + """ + cls._db[user.id] = user + + @classmethod + def delete(cls, user_id: int) -> None: + """ + Delete a user from the database by their ID. + + Args: + user_id: The ID of the user to delete. + + Raises: + KeyError: If the user does not exist. + """ + if user_id not in cls._db: + msg = f"User {user_id} does not exist." + raise KeyError(msg) + del cls._db[user_id] + + @classmethod + def get(cls, user_id: int) -> User | None: + """ + Retrieve a user by their ID. + + Args: + user_id: The ID of the user to retrieve. + + Returns: + The User instance if found, else None. + """ + return cls._db.get(user_id) + + +app = FastAPI() + + +@app.get("/users/{uid}") +async def get_user_by_id(uid: int) -> Response: + """ + Retrieve a user by their ID, returning an XML response. + + Args: + uid: + The user ID to retrieve. + + Raises: + HTTPException: If the user is not found, a 404 error is raised. + """ + logger.debug("GET /users/%s", uid) + user = UserDb.get(uid) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + root = ET.Element("user") + ET.SubElement(root, "id").text = str(user.id) + ET.SubElement(root, "name").text = user.name + return Response( + content=ET.tostring(root, encoding="unicode"), + media_type="application/xml", + ) diff --git a/examples/http/xml_example/pyproject.toml b/examples/http/xml_example/pyproject.toml new file mode 100644 index 000000000..3e72798e8 --- /dev/null +++ b/examples/http/xml_example/pyproject.toml @@ -0,0 +1,29 @@ +#:schema https://www.schemastore.org/pyproject.json + +[project] +name = "example-xml" + +description = "Example of XML contract testing with Pact Python" + +version = "1.0.0" +requires-python = ">=3.10" +dependencies = ["fastapi~=0.0", "requests~=2.0", "typing-extensions~=4.0"] + +[dependency-groups] +test = ["pact-python", "pytest~=9.0", "uvicorn~=0.29"] + +[tool] + [tool.pytest] + addopts = ["--import-mode=importlib"] + + asyncio_default_fixture_loop_scope = "session" + + log_date_format = "%H:%M:%S" + log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" + log_level = "NOTSET" + + [tool.ruff] + extend = "../../../pyproject.toml" + + [tool.uv.sources] + pact-python = { path = "../../../" } diff --git a/examples/http/xml_example/test_consumer.py b/examples/http/xml_example/test_consumer.py new file mode 100644 index 000000000..c96dfb2af --- /dev/null +++ b/examples/http/xml_example/test_consumer.py @@ -0,0 +1,167 @@ +""" +Consumer contract tests using Pact (XML). + +This module demonstrates how to test a consumer (see +[`consumer.py`][examples.http.xml_example.consumer]) against a mock provider +using Pact. The mock provider is set up by Pact to validate that the consumer +makes the expected requests and can handle the provider's responses. Once +validated, the contract is written to a file for use in provider verification. + +## XML matchers + +Unlike a literal XML string, the response body can be expressed using the +`pact.xml` builder. This allows standard Pact matchers (such as +`match.int()` and `match.str()`) to be embedded in the body description, so +the contract specifies *structural constraints* rather than exact values. + +Whereas a JSON body can be described as: + +```python +response = {"id": match.int(123), "name": match.str("Alice")} +``` + +An equivalent XML body is described with the `xml` builder as: + +```python +from pact import match, xml + +response = xml.body( + xml.element( + "user", + xml.element("id", match.int(123)), + xml.element("name", match.str("Alice")), + ) +) +``` + +Pass this dict to `.with_body()` with `content_type="application/xml"`. The +Pact FFI detects that the body is JSON, generates the example XML, and +registers matching rules for each annotated element. The resulting contract +will match _any_ XML response where `` contains an integer and `` +contains a string and not just the specific example values. + +For attribute matchers, pass matcher objects via the `attrs` keyword argument: + +```python +xml.element("user", attrs={"id": match.int(1), "type": "activity"}) +``` + +## Setting request headers + +`with_request()` does not accept a `headers` argument. Instead, use a +subsequent `.with_header()` call on the same interaction chain, as shown in the +tests below. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import pytest +import requests + +from examples.http.xml_example.consumer import UserClient +from pact import Pact, match, xml + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def pact(pacts_path: Path) -> Generator[Pact, None, None]: + """ + Set up a Pact mock provider for consumer tests. + + This fixture creates a `Pact` object that acts as a mock + provider. Each test defines the expected request and response using the Pact + DSL, and Pact spins up a local HTTP server that validates the consumer + makes exactly those requests. This allows the consumer to be tested in + complete isolation from the real provider, no running provider is needed + at this stage. + + After all tests in a session have run, `write_file` serialises the + recorded interactions to a contract file. This file is then used by the + [provider test][examples.http.xml_example.test_provider] to verify that + the real provider honours the contract. + + Args: + pacts_path: + The path where the generated pact file will be written. + + Yields: + A Pact object for use in tests. + """ + pact = Pact("xml-consumer", "xml-provider").with_specification("V4") + yield pact + pact.write_file(pacts_path) + + +def test_get_user(pact: Pact) -> None: + """ + Test the GET request for a user, expecting an XML response with matchers. + + This test defines the expected interaction for a successful user lookup. + It demonstrates how to use `xml.body()` and `xml.element()` to specify + structural constraints on + the response body: the `id` element must contain an integer and the `name` + element must contain a non-null string, but their exact values do not + matter. The `Accept: application/xml` request header is registered via a + separate `.with_header()` call after `.with_request()`. + """ + response = xml.body( + xml.element( + "user", + xml.element("id", match.int(123)), + xml.element("name", match.str("Alice")), + ) + ) + ( + pact + .upon_receiving("A request for a user as XML") + .given("the user exists", id=123, name="Alice") + .with_request("GET", "/users/123") + .with_header("Accept", "application/xml") + .will_respond_with(200) + .with_body(response, content_type="application/xml") + ) + + with ( + pact.serve() as srv, + UserClient(str(srv.url)) as client, + ): + user = client.get_user(123) + assert user.id == 123 + assert user.name == "Alice" + + +def test_get_unknown_user(pact: Pact) -> None: + """ + Test the GET request for an unknown user, expecting a 404 response. + + This test verifies that the consumer correctly handles a 404 error by + propagating a `requests.HTTPError` (via `raise_for_status()`). No response + body is matched: when the provider returns a 404, FastAPI produces a JSON + error body, but this consumer does not inspect the error body; it only + checks the status code and raises. Explicitly omitting `.with_body()` here + communicates that the consumer's contract requirement is limited to the + status code, not the error payload. + """ + ( + pact + .upon_receiving("A request for an unknown user as XML") + .given("the user doesn't exist", id=123) + .with_request("GET", "/users/123") + .with_header("Accept", "application/xml") + .will_respond_with(404) + ) + + with ( + pact.serve() as srv, + UserClient(str(srv.url)) as client, + pytest.raises(requests.HTTPError), + ): + client.get_user(123) diff --git a/examples/http/xml_example/test_provider.py b/examples/http/xml_example/test_provider.py new file mode 100644 index 000000000..538147771 --- /dev/null +++ b/examples/http/xml_example/test_provider.py @@ -0,0 +1,182 @@ +""" +Provider contract tests using Pact (XML). + +This module demonstrates how to test a FastAPI provider (see +[`provider.py`][examples.http.xml_example.provider]) against a mock consumer +using Pact. The mock consumer replays the requests defined by the consumer +contract, and Pact validates that the provider responds as expected. + +These tests show how provider verification ensures that the provider remains +compatible with the consumer contract as the provider evolves. Provider state +management is handled by manipulating the in-memory database directly before +each interaction is replayed. For more, see the [Pact Provider +Test](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-provider-pact-test) +section of the Pact documentation. +""" + +from __future__ import annotations + +import contextlib +import logging +import socket +import time +from threading import Thread +from typing import TYPE_CHECKING, Any, Literal + +import pytest +import uvicorn + +import pact._util +from examples.http.xml_example.provider import User, UserDb, app +from pact import Verifier + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="session") +def app_server() -> str: + """ + Run the FastAPI server for provider verification. + + Returns: + The base URL of the running FastAPI server. + """ + hostname = "localhost" + port = pact._util.find_free_port() # noqa: SLF001 + Thread( + target=uvicorn.run, + args=(app,), + kwargs={"host": hostname, "port": port}, + daemon=True, + ).start() + for _ in range(50): + with ( + contextlib.suppress(ConnectionRefusedError, OSError), + socket.create_connection((hostname, port), timeout=0.1), + ): + break + time.sleep(0.1) + return f"http://{hostname}:{port}" + + +def test_provider(app_server: str, pacts_path: Path) -> None: + """ + Test the provider against the mock consumer contract. + + This test runs the Pact verifier against the FastAPI provider, using the + contract generated by the consumer tests. + + Provider state handlers are essential in Pact contract testing. They allow + the provider to be set up in a specific state before each interaction is + verified. For example, if a consumer expects a user to exist for a certain + request, the provider state handler ensures the in-memory database is + populated accordingly. This enables repeatable and isolated contract + verification: each interaction is tested in the correct context without + relying on global or persistent state. + + In this example, `mock_user_exists` and `mock_user_does_not_exist` are + mapped to the state names declared in the consumer contract. They are + responsible for setting up (and tearing down) the database so that the + provider can respond correctly to each replayed request. + + For additional information on state handlers, see + [`Verifier.state_handler`][pact.verifier.Verifier.state_handler]. + """ + verifier = ( + Verifier("xml-provider") + .add_source(pacts_path) + .add_transport(url=app_server) + .state_handler( + { + "the user exists": mock_user_exists, + "the user doesn't exist": mock_user_does_not_exist, + }, + teardown=True, + ) + ) + + verifier.verify() + + +def mock_user_exists( + action: Literal["setup", "teardown"], + parameters: dict[str, Any], +) -> None: + """ + Mock the provider state where a user exists. + + This handler is invoked by Pact before and after each interaction that + declares the state `"the user exists"`. On setup, a `User` record is + inserted into the in-memory database using the `id` and `name` parameters + from the consumer contract. On teardown, the record is removed so that + subsequent interactions start from a clean slate. + + Args: + action: + Either `"setup"` (called before the interaction) or `"teardown"` + (called after). + parameters: + State parameters from the consumer contract. Must contain `"id"`; + `"name"` is optional and defaults to `"Alice"`. + """ + logger.debug("mock_user_exists(%s, %r)", action, parameters) + user = User( + id=int(parameters.get("id", 1)), + name=str(parameters.get("name", "Alice")), + ) + + if action == "setup": + UserDb.create(user) + return + + if action == "teardown": + with contextlib.suppress(KeyError): + UserDb.delete(user.id) + return + + msg = f"Unknown action: {action}" + raise ValueError(msg) + + +def mock_user_does_not_exist( + action: Literal["setup", "teardown"], + parameters: dict[str, Any], +) -> None: + """ + Mock the provider state where a user does not exist. + + This handler is invoked by Pact before and after each interaction that + declares the state `"the user doesn't exist"`. On setup, any existing user + with the given `id` is removed from the in-memory database, ensuring the + provider will return a 404. On teardown, nothing needs to be restored + because nothing was added during setup, the user simply remains absent. + + Args: + action: + Either `"setup"` (called before the interaction) or `"teardown"` + (called after). + parameters: + State parameters from the consumer contract. Must contain `"id"`. + """ + logger.debug("mock_user_does_not_exist(%s, %r)", action, parameters) + + if "id" not in parameters: + msg = "State must contain an 'id' field to mock user non-existence" + raise ValueError(msg) + + uid = int(parameters["id"]) + + if action == "setup": + if user := UserDb.get(uid): + UserDb.delete(user.id) + return + + if action == "teardown": + # Nothing was added during setup, so there is nothing to clean up. + return + + msg = f"Unknown action: {action}" + raise ValueError(msg) diff --git a/examples/message/README.md b/examples/message/README.md deleted file mode 100644 index 13c738057..000000000 --- a/examples/message/README.md +++ /dev/null @@ -1,246 +0,0 @@ -# Introduction - -This is an e2e example that uses messages, including a sample implementation of a message handler. - -## Consumer - -A Consumer is the system that will be reading a message from a queue or some intermediary. In this example, the consumer is a Lambda function that handles the message. - -From a Pact testing point of view, Pact takes the place of the intermediary (MQ/broker etc.) and confirms whether or not the consumer is able to handle a request. - -``` -+-----------+ +-------------------+ -| (Pact) | message |(Message Consumer) | -| MQ/broker |--------->|Lambda Function | -| | |check valid doc | -+-----------+ +-------------------+ -``` - -Below is a sample message handler that only accepts that the key `documentType` would only be `microsoft-word`. If not, the message handler will throw an exception (`CustomError`) - -```python -class CustomError(Exception): - def __init__(self, *args): - if args: - self.topic = args[0] - else: - self.topic = None - - def __str__(self): - if self.topic: - return 'Custom Error:, {0}'.format(self.topic) - -class MessageHandler(object): - def __init__(self, event): - self.pass_event(event) - - @staticmethod - def pass_event(event): - if event.get('documentType') != 'microsoft-word': - raise CustomError("Not correct document type") -``` - -Below is a snippet from a test where the message handler has no error. -Since the expected event contains a key `documentType` with value `microsoft-word`, message handler does not throw an error and a pact file `f"{PACT_FILE}""` is expected to be generated. - -```python -def test_generate_new_pact_file(pact): - cleanup_json(PACT_FILE) - - expected_event = { - 'documentName': 'document.doc', - 'creator': 'TP', - 'documentType': 'microsoft-word' - } - - (pact - .given('A document create in Document Service') - .expects_to_receive('Description') - .with_content(expected_event) - .with_metadata({ - 'Content-Type': 'application/json' - })) - - with pact: - # handler needs 'documentType' == 'microsoft-word' - MessageHandler(expected_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 1 -``` - -For a similar test where the event does not contain a key `documentType` with value `microsoft-word`, a `CustomError` is generated and there there is no generated json file `f"{PACT_FILE}"`. - -```python -def test_throw_exception_handler(pact): - cleanup_json(PACT_FILE) - wrong_event = { - 'documentName': 'spreadsheet.xls', - 'creator': 'WI', - 'documentType': 'microsoft-excel' - } - - (pact - .given('Another document in Document Service') - .expects_to_receive('Description') - .with_content(wrong_event) - .with_metadata({ - 'Content-Type': 'application/json' - })) - - with pytest.raises(CustomError): - with pact: - # handler needs 'documentType' == 'microsoft-word' - MessageHandler(wrong_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 0 -``` - -## Provider - -``` -+-------------------+ +-----------+ -|(Message Provider) | message | (Pact) | -|Document Upload |--------->| MQ/broker | -|Service | | | -+-------------------+ +-----------+ -``` - -```python -import pytest -from pact import MessageProvider - -def document_created_handler(): - return { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - -def test_verify_success(): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - with provider: - provider.verify() -``` - - -### Provider with pact broker -```python -import pytest -from pact import MessageProvider - - -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -PACT_DIR = "pacts" - - -@pytest.fixture -def default_opts(): - return { - 'broker_username': PACT_BROKER_USERNAME, - 'broker_password': PACT_BROKER_PASSWORD, - 'broker_url': PACT_BROKER_URL, - 'publish_version': '3', - 'publish_verification_results': False - } - -def document_created_handler(): - return { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - -def test_verify_from_broker(default_opts): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - - with pytest.raises(AssertionError): - with provider: - provider.verify_with_broker(**default_opts) - -``` - -## E2E Messaging - -``` -+-------------------+ +-----------+ +-------------------+ -|(Message Provider) | message | (Pact) | message |(Message Consumer) | -|Document Upload |--------->| MQ/broker |--------->|Lambda Function | -|Service | | | |check valid doc | -+-------------------+ +-----------+ +-------------------+ -``` - -# Setup - -## Virtual Environment - -Go to the `example/message` directory Create your own virtualenv for this. Run - -```bash -pip install -r requirements.txt -pip install -e ../../ -pytest -``` - -## Message Consumer - -From the root directory run: - -```bash -pytest -``` - -Or you can run individual tests like: - -```bash -pytest tests/consumer/test_message_consumer.py::test_generate_new_pact_file -``` - -## With Broker - -The current consumer test can run even without a local broker, -but this is added for demo purposes. - -Open a separate terminal in the `examples/broker` folder and run: - -```bash -docker-compose up -``` - -Open a browser to http://localhost and see the broker you have succeeded. -If needed, log-in using the provided details in tests such as: - -``` -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -``` - -To get the consumer to publish a pact to broker, -open a new terminal in the `examples/message` and run the following (2 is an arbitary version number). The first part makes sure that the an existing json has been generated: - -```bash -pytest tests/consumer/test_message_consumer.py::test_publish_to_broker -pytest tests/consumer/test_message_consumer.py::test_publish_to_broker --publish-pact 2 -``` diff --git a/examples/message/conftest.py b/examples/message/conftest.py deleted file mode 100644 index 90ac63b9a..000000000 --- a/examples/message/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import sys - -# Load in the fixtures from common/sharedfixtures.py -sys.path.append("../common") - -pytest_plugins = [ - "sharedfixtures", -] diff --git a/examples/message/requirements.txt b/examples/message/requirements.txt deleted file mode 100644 index 279c15d14..000000000 --- a/examples/message/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -Flask==2.0.3; python_version < '3.7' -Flask==2.2.2; python_version >= '3.7' -pytest==7.0.1; python_version < '3.7' -pytest==7.1.3; python_version >= '3.7' -requests==2.27.1; python_version < '3.7' -requests>=2.28.0; python_version >= '3.7' diff --git a/examples/message/run_pytest.sh b/examples/message/run_pytest.sh deleted file mode 100755 index 35dade4fb..000000000 --- a/examples/message/run_pytest.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -set -o pipefail - -pytest --run-broker True --publish-pact 2 - -# publish to broker assuming broker is active -# pytest tests/consumer/test_message_consumer.py::test_publish_to_broker --publish-pact 2 diff --git a/examples/message/src/message_handler.py b/examples/message/src/message_handler.py deleted file mode 100644 index 1be2b4641..000000000 --- a/examples/message/src/message_handler.py +++ /dev/null @@ -1,19 +0,0 @@ -class CustomError(Exception): - def __init__(self, *args): - if args: - self.topic = args[0] - else: - self.topic = None - - def __str__(self): - if self.topic: - return 'Custom Error:, {0}'.format(self.topic) - -class MessageHandler(object): - def __init__(self, event): - self.pass_event(event) - - @staticmethod - def pass_event(event): - if event.get('documentType') != 'microsoft-word': - raise CustomError("Not correct document type") diff --git a/examples/message/tests/consumer/test_message_consumer.py b/examples/message/tests/consumer/test_message_consumer.py deleted file mode 100644 index 3c7de4c1a..000000000 --- a/examples/message/tests/consumer/test_message_consumer.py +++ /dev/null @@ -1,156 +0,0 @@ -"""pact test for a message consumer""" - -import logging -import pytest -import time - -from os import remove -from os.path import isfile - -from pact import MessageConsumer, Provider -from src.message_handler import MessageHandler, CustomError - -log = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -PACT_DIR = "pacts" - -CONSUMER_NAME = "DetectContentLambda" -PROVIDER_NAME = "ContentProvider" -PACT_FILE = (f"{PACT_DIR}/{CONSUMER_NAME.lower().replace(' ', '_')}-" - + f"{PROVIDER_NAME.lower().replace(' ', '_')}.json") - - -@pytest.fixture(scope="session") -def pact(request): - version = request.config.getoption("--publish-pact") - publish = True if version else False - - pact = MessageConsumer(CONSUMER_NAME, version=version).has_pact_with( - Provider(PROVIDER_NAME), - publish_to_broker=publish, broker_base_url=PACT_BROKER_URL, - broker_username=PACT_BROKER_USERNAME, broker_password=PACT_BROKER_PASSWORD, pact_dir=PACT_DIR) - - yield pact - - -@pytest.fixture(scope="session") -def pact_no_publish(request): - version = request.config.getoption("--publish-pact") - pact = MessageConsumer(CONSUMER_NAME, version=version).has_pact_with( - Provider(PROVIDER_NAME), - publish_to_broker=False, broker_base_url=PACT_BROKER_URL, - broker_username=PACT_BROKER_USERNAME, broker_password=PACT_BROKER_PASSWORD, pact_dir=PACT_DIR) - - yield pact - -def cleanup_json(file): - """ - Remove existing json file before test if any - """ - if (isfile(f"{file}")): - remove(f"{file}") - - -def progressive_delay(file, time_to_wait=10, second_interval=0.5, verbose=False): - """ - progressive delay - defaults to wait up to 5 seconds with 0.5 second intervals - """ - time_counter = 0 - while not isfile(file): - time.sleep(second_interval) - time_counter += 1 - if verbose: - print(f"Trying for {time_counter*second_interval} seconds") - if time_counter > time_to_wait: - if verbose: - print(f"Already waited {time_counter*second_interval} seconds") - break - - -def test_throw_exception_handler(pact_no_publish): - cleanup_json(PACT_FILE) - - wrong_event = { - "event": "ObjectCreated:Put", - "documentName": "spreadsheet.xls", - "creator": "WI", - "documentType": "microsoft-excel" - } - - (pact_no_publish - .given("Document unsupported type") - .expects_to_receive("Description") - .with_content(wrong_event) - .with_metadata({ - "Content-Type": "application/json" - })) - - with pytest.raises(CustomError): - with pact_no_publish: - # handler needs "documentType" == "microsoft-word" - MessageHandler(wrong_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 0 - - -def test_put_file(pact_no_publish): - cleanup_json(PACT_FILE) - - expected_event = { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - (pact_no_publish - .given("A document created successfully") - .expects_to_receive("Description") - .with_content(expected_event) - .with_metadata({ - "Content-Type": "application/json" - })) - - with pact_no_publish: - MessageHandler(expected_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 1 - - -def test_publish_to_broker(pact): - """ - This test does not clean-up previously generated pact. - Sample execution where 2 is an arbitrary version: - - `pytest tests/consumer/test_message_consumer.py::test_publish_pact_to_broker` - - `pytest tests/consumer/test_message_consumer.py::test_publish_pact_to_broker --publish-pact 2` - """ - - expected_event = { - "event": "ObjectCreated:Delete", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - (pact - .given("A document deleted successfully") - .expects_to_receive("Description with broker") - .with_content(expected_event) - .with_metadata({ - "Content-Type": "application/json" - })) - - with pact: - MessageHandler(expected_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 1 diff --git a/examples/message/tests/provider/test_message_provider.py b/examples/message/tests/provider/test_message_provider.py deleted file mode 100644 index ae20ab3a1..000000000 --- a/examples/message/tests/provider/test_message_provider.py +++ /dev/null @@ -1,83 +0,0 @@ -import pytest -from pact import MessageProvider - -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -PACT_DIR = "pacts" - - -@pytest.fixture -def default_opts(): - return { - 'broker_username': PACT_BROKER_USERNAME, - 'broker_password': PACT_BROKER_PASSWORD, - 'broker_url': PACT_BROKER_URL, - 'publish_version': '3', - 'publish_verification_results': False - } - - -def document_created_handler(): - return { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - -def document_deleted_handler(): - return { - "event": "ObjectCreated:Delete", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - -def test_verify_success(): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - 'A document deleted successfully': document_deleted_handler - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - with provider: - provider.verify() - - -def test_verify_failure_when_a_provider_missing(): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - - with pytest.raises(AssertionError): - with provider: - provider.verify() - - -def test_verify_from_broker(default_opts): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - 'A document deleted successfully': document_deleted_handler - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - - with provider: - provider.verify_with_broker(**default_opts) diff --git a/examples/pacts/userserviceclient-userservice.json b/examples/pacts/userserviceclient-userservice.json deleted file mode 100644 index 5453494e2..000000000 --- a/examples/pacts/userserviceclient-userservice.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "consumer": { - "name": "UserServiceClient" - }, - "provider": { - "name": "UserService" - }, - "interactions": [ - { - "description": "a request for UserA", - "providerState": "UserA exists and is not an administrator", - "request": { - "method": "get", - "path": "/users/UserA" - }, - "response": { - "status": 200, - "headers": { - }, - "body": { - "name": "UserA", - "id": "fc763eba-0905-41c5-a27f-3934ab26786c", - "created_on": "2016-12-15T20:16:01", - "ip_address": "127.0.0.1", - "admin": false - }, - "matchingRules": { - "$.body": { - "match": "type" - }, - "$.body.id": { - "match": "regex", - "regex": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" - }, - "$.body.created_on": { - "match": "regex", - "regex": "\\d+-\\d+-\\d+T\\d+:\\d+:\\d+" - }, - "$.body.ip_address": { - "match": "regex", - "regex": "(\\d{1,3}\\.)+\\d{1,3}" - } - } - } - }, - { - "description": "a request for UserA", - "providerState": "UserA does not exist", - "request": { - "method": "get", - "path": "/users/UserA" - }, - "response": { - "status": 404, - "headers": { - } - } - } - ], - "metadata": { - "pactSpecification": { - "version": "2.0.0" - } - } -} \ No newline at end of file diff --git a/examples/plugins/__init__.py b/examples/plugins/__init__.py new file mode 100644 index 000000000..6e031999e --- /dev/null +++ b/examples/plugins/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/examples/plugins/proto/__init__.py b/examples/plugins/proto/__init__.py new file mode 100644 index 000000000..8066dab26 --- /dev/null +++ b/examples/plugins/proto/__init__.py @@ -0,0 +1,100 @@ +r""" +Protocol Buffers (protobuf) support for Pact Python examples. + +This module contains the generated Python code from the protobuf definition in +`person.proto`, which is used by both the `protobuf` and `grpc` examples. These +examples are designed to be pedagogical, demonstrating how to use Pact for +contract testing with Protocol Buffers and gRPC services. + +## What is Protocol Buffers (protobuf)? + +Protocol Buffers (protobuf) is Google's language-neutral, platform-neutral, +extensible mechanism for serializing structured data. Think of it like XML or +JSON, but smaller, faster, and simpler. You define your data structures in a +`.proto` file, and then use the protobuf compiler to generate classes in your +programming language of choice. + +For the purposes of our examples, we define `person.proto` which defines the +following messages: + +- `Person`: Represents a person with name, ID, email, and phone numbers +- `AddressBook`: A collection of Person objects +- Request/Response messages for service operations: + + - `GetPersonRequest/Response`: For retrieving a person by ID + - `ListPeopleRequest/Response`: For listing all people + - `AddPersonRequest/Response`: For adding a new person + +The file also defines a single service (useful for the gRPC example): + +- `AddressBookService`: Defines gRPC service methods for managing an address + book + +## Generated Files + +The `.proto` file is used by `protoc` and other tools to generate Python code. +The three files present in this directory can be generated using: + +```bash +python -m grpc_tools.protoc \ + -I. \ # (1) + --python_out=. \ # (2) + --pyi_out=. \ # (3) + --grpc_python_out=. \ # (4) + person.proto +``` + +1. `-I.` is used by the gRPC code generator to allow it to import the + `person_pb2` module. +2. `--python_out=.` specifies the output directory for the generated Python + code files. +3. `--pyi_out=.` specifies the output directory for the generated Python stub + files. +4. `--grpc_python_out=.` specifies the output directory for the generated gRPC + service files. + +### `person_pb2.py` + +**Purpose**: Contains the core protobuf message classes and serialization logic. + +**What it does**: + +- Defines Python classes for each protobuf message (Person, AddressBook, etc.) +- Provides methods for serializing objects to binary format + (`SerializeToString()`) +- Provides methods for deserializing binary data back to objects + (`ParseFromString()`) +- Handles all the low-level protobuf protocol details + +### `person_pb2.pyi` + +**Purpose**: Type stub file providing type hints for better IDE support and +static analysis. + +**What it does**: + +- Provides type annotations for all generated classes and methods +- Enables better autocomplete and type checking in IDEs like VSCode or PyCharm +- Helps static type checkers like mypy understand the generated code +- Makes the code more maintainable and less error-prone + +### `person_pb2_grpc.py` + +**Purpose**: Contains gRPC service client and server classes. + +**What it does**: + +- Defines client stub classes for calling gRPC services +- Defines server base classes for implementing gRPC services +- Handles the gRPC protocol layer (HTTP/2, streaming, etc.) +- Maps protobuf messages to gRPC method calls + +## Learning Resources + +For more information about Protocol Buffers and gRPC: + +- [Protocol Buffers + Tutorial](https://protobuf.dev/getting-started/pythontutorial/) +- [gRPC Python Tutorial](https://grpc.io/docs/languages/python/) +- [Pact gRPC/Protobuf Plugin](https://github.com/pactflow/pact-protobuf-plugin) +""" diff --git a/examples/plugins/proto/person.proto b/examples/plugins/proto/person.proto new file mode 100644 index 000000000..1f6b2fd7c --- /dev/null +++ b/examples/plugins/proto/person.proto @@ -0,0 +1,72 @@ +// examples/v3/plugins/proto/person.proto +edition = "2023"; + +package person; + +// The person message definition +message Person { + string name = 1; + int32 id = 2; + string email = 3; + + enum PhoneType { + PHONE_TYPE_UNSPECIFIED = 0; + PHONE_TYPE_MOBILE = 1; + PHONE_TYPE_HOME = 2; + PHONE_TYPE_WORK = 3; + } + + message PhoneNumber { + string number = 1; + PhoneType type = 2 [default = PHONE_TYPE_HOME]; + } + + repeated PhoneNumber phones = 4; +} + +// The address book message definition +message AddressBook { + repeated Person people = 1; +} + +// Request message for getting a person by ID +message GetPersonRequest { + int32 person_id = 1; +} + +// Response message for getting a person +message GetPersonResponse { + Person person = 1; +} + +// Request message for listing all people +message ListPeopleRequest { + // Can add pagination parameters here in the future +} + +// Response message for listing people +message ListPeopleResponse { + repeated Person people = 1; +} + +// Request message for adding a person +message AddPersonRequest { + Person person = 1; +} + +// Response message for adding a person +message AddPersonResponse { + Person person = 1; +} + +// The AddressBook service definition +service AddressBookService { + // Get a person by ID + rpc GetPerson(GetPersonRequest) returns (GetPersonResponse); + + // List all people in the address book + rpc ListPeople(ListPeopleRequest) returns (ListPeopleResponse); + + // Add a new person to the address book + rpc AddPerson(AddPersonRequest) returns (AddPersonResponse); +} diff --git a/examples/plugins/proto/person_pb2.py b/examples/plugins/proto/person_pb2.py new file mode 100644 index 000000000..3d0c20d40 --- /dev/null +++ b/examples/plugins/proto/person_pb2.py @@ -0,0 +1,62 @@ +# ruff: noqa: PGH004 +# ruff: noqa +# source: person.proto +# Protobuf Python Version: 6.31.0 +""" +Protocol buffer message and service definitions for the AddressBook pedagogical example. + +This module is auto-generated from the person.proto file using the protobuf compiler. It provides Python classes for all messages and services defined in the proto file, and is intended for use in educational and demonstration contexts. + +/// note +This file is generated code. Manual changes (except for documentation improvements) will be overwritten if the file is regenerated. +/// +""" + +from __future__ import annotations + +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder + +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, 6, 31, 0, "", "person.proto" +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n\x0cperson.proto\x12\x06person"\x9f\x02\n\x06Person\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\x05\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12*\n\x06phones\x18\x04 \x03(\x0b\x32\x1a.person.Person.PhoneNumber\x1aV\n\x0bPhoneNumber\x12\x0e\n\x06number\x18\x01 \x01(\t\x12\x37\n\x04type\x18\x02 \x01(\x0e\x32\x18.person.Person.PhoneType:\x0fPHONE_TYPE_HOME"h\n\tPhoneType\x12\x1a\n\x16PHONE_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11PHONE_TYPE_MOBILE\x10\x01\x12\x13\n\x0fPHONE_TYPE_HOME\x10\x02\x12\x13\n\x0fPHONE_TYPE_WORK\x10\x03"-\n\x0b\x41\x64\x64ressBook\x12\x1e\n\x06people\x18\x01 \x03(\x0b\x32\x0e.person.Person"%\n\x10GetPersonRequest\x12\x11\n\tperson_id\x18\x01 \x01(\x05"3\n\x11GetPersonResponse\x12\x1e\n\x06person\x18\x01 \x01(\x0b\x32\x0e.person.Person"\x13\n\x11ListPeopleRequest"4\n\x12ListPeopleResponse\x12\x1e\n\x06people\x18\x01 \x03(\x0b\x32\x0e.person.Person"2\n\x10\x41\x64\x64PersonRequest\x12\x1e\n\x06person\x18\x01 \x01(\x0b\x32\x0e.person.Person"3\n\x11\x41\x64\x64PersonResponse\x12\x1e\n\x06person\x18\x01 \x01(\x0b\x32\x0e.person.Person2\xdd\x01\n\x12\x41\x64\x64ressBookService\x12@\n\tGetPerson\x12\x18.person.GetPersonRequest\x1a\x19.person.GetPersonResponse\x12\x43\n\nListPeople\x12\x19.person.ListPeopleRequest\x1a\x1a.person.ListPeopleResponse\x12@\n\tAddPerson\x12\x18.person.AddPersonRequest\x1a\x19.person.AddPersonResponseb\x08\x65\x64itionsp\xe8\x07' +) + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "person_pb2", _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals["_PERSON"]._serialized_start = 25 + _globals["_PERSON"]._serialized_end = 312 + _globals["_PERSON_PHONENUMBER"]._serialized_start = 120 + _globals["_PERSON_PHONENUMBER"]._serialized_end = 206 + _globals["_PERSON_PHONETYPE"]._serialized_start = 208 + _globals["_PERSON_PHONETYPE"]._serialized_end = 312 + _globals["_ADDRESSBOOK"]._serialized_start = 314 + _globals["_ADDRESSBOOK"]._serialized_end = 359 + _globals["_GETPERSONREQUEST"]._serialized_start = 361 + _globals["_GETPERSONREQUEST"]._serialized_end = 398 + _globals["_GETPERSONRESPONSE"]._serialized_start = 400 + _globals["_GETPERSONRESPONSE"]._serialized_end = 451 + _globals["_LISTPEOPLEREQUEST"]._serialized_start = 453 + _globals["_LISTPEOPLEREQUEST"]._serialized_end = 472 + _globals["_LISTPEOPLERESPONSE"]._serialized_start = 474 + _globals["_LISTPEOPLERESPONSE"]._serialized_end = 526 + _globals["_ADDPERSONREQUEST"]._serialized_start = 528 + _globals["_ADDPERSONREQUEST"]._serialized_end = 578 + _globals["_ADDPERSONRESPONSE"]._serialized_start = 580 + _globals["_ADDPERSONRESPONSE"]._serialized_end = 631 + _globals["_ADDRESSBOOKSERVICE"]._serialized_start = 634 + _globals["_ADDRESSBOOKSERVICE"]._serialized_end = 855 +# @@protoc_insertion_point(module_scope) diff --git a/examples/plugins/proto/person_pb2.pyi b/examples/plugins/proto/person_pb2.pyi new file mode 100644 index 000000000..a8545eabe --- /dev/null +++ b/examples/plugins/proto/person_pb2.pyi @@ -0,0 +1,217 @@ +""" +Type stubs for protocol buffer messages and services for the AddressBook pedagogical example. + +This module is auto-generated from the person.proto file and provides type hints for all messages and services defined in the proto file. It is intended for use in educational and demonstration contexts, and helps with static analysis and editor support. + +/// note +This file is generated code. Manual changes (except for documentation improvements) will be overwritten if the file is regenerated. +/// +""" + +# ruff: noqa: PGH004 +# ruff: noqa +from collections.abc import Iterable as _Iterable +from collections.abc import Mapping as _Mapping +from typing import ClassVar as _ClassVar +from typing import Optional as _Optional +from typing import Union as _Union + +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper + +DESCRIPTOR: _descriptor.FileDescriptor + +class Person(_message.Message): + """ + Represents a person in the AddressBook example. + """ + + __slots__ = ("email", "id", "name", "phones") + class PhoneType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + """ + Enum for the type of phone number. + """ + + __slots__ = () + PHONE_TYPE_UNSPECIFIED: _ClassVar[Person.PhoneType] + PHONE_TYPE_MOBILE: _ClassVar[Person.PhoneType] + PHONE_TYPE_HOME: _ClassVar[Person.PhoneType] + PHONE_TYPE_WORK: _ClassVar[Person.PhoneType] + + PHONE_TYPE_UNSPECIFIED: Person.PhoneType + PHONE_TYPE_MOBILE: Person.PhoneType + PHONE_TYPE_HOME: Person.PhoneType + PHONE_TYPE_WORK: Person.PhoneType + class PhoneNumber(_message.Message): + """ + Represents a phone number for a person. + """ + + __slots__ = ("number", "type") + NUMBER_FIELD_NUMBER: _ClassVar[int] + TYPE_FIELD_NUMBER: _ClassVar[int] + number: str + type: Person.PhoneType + def __init__( + self, + number: _Optional[str] = ..., + type: _Optional[_Union[Person.PhoneType, str]] = ..., + ) -> None: + """ + Create a new PhoneNumber instance. + + Args: + number: + The phone number as a string. + type: + The type of phone number (e.g., mobile, home, work). + """ + + NAME_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + EMAIL_FIELD_NUMBER: _ClassVar[int] + PHONES_FIELD_NUMBER: _ClassVar[int] + name: str + id: int + email: str + phones: _containers.RepeatedCompositeFieldContainer[Person.PhoneNumber] + def __init__( + self, + name: _Optional[str] = ..., + id: _Optional[int] = ..., + email: _Optional[str] = ..., + phones: _Optional[_Iterable[_Union[Person.PhoneNumber, _Mapping]]] = ..., + ) -> None: + """ + Creates a new Person instance. + + Args: + name: + The person's name. + id: + The unique identifier for the person. + email: + The person's email address. + phones: + A list of phone numbers for the person. + """ + +class AddressBook(_message.Message): + """ + Represents an address book containing multiple people. + """ + + __slots__ = ("people",) + PEOPLE_FIELD_NUMBER: _ClassVar[int] + people: _containers.RepeatedCompositeFieldContainer[Person] + def __init__( + self, + people: _Optional[_Iterable[_Union[Person, _Mapping]]] = ..., + ) -> None: + """ + Creates a new AddressBook instance. + + Args: + people: A list of Person objects in the address book. + """ + +class GetPersonRequest(_message.Message): + """ + Request message for retrieving a person by ID. + """ + + __slots__ = ("person_id",) + PERSON_ID_FIELD_NUMBER: _ClassVar[int] + person_id: int + def __init__(self, person_id: _Optional[int] = ...) -> None: + """ + Creates a new GetPersonRequest instance. + + Args: + person_id: + The unique identifier of the person to retrieve. + """ + +class GetPersonResponse(_message.Message): + """ + Response message containing a single person. + """ + + __slots__ = ("person",) + PERSON_FIELD_NUMBER: _ClassVar[int] + person: Person + def __init__(self, person: _Optional[_Union[Person, _Mapping]] = ...) -> None: + """ + Creates a new GetPersonResponse instance. + + Args: + person: + The Person object returned by the service. + """ + +class ListPeopleRequest(_message.Message): + """ + Request message for listing all people in the address book. + """ + + __slots__ = () + def __init__(self) -> None: + """ + Creates a new ListPeopleRequest instance. + """ + +class ListPeopleResponse(_message.Message): + """ + Response message containing a list of people. + """ + + __slots__ = ("people",) + PEOPLE_FIELD_NUMBER: _ClassVar[int] + people: _containers.RepeatedCompositeFieldContainer[Person] + def __init__( + self, + people: _Optional[_Iterable[_Union[Person, _Mapping]]] = ..., + ) -> None: + """ + Creates a new ListPeopleResponse instance. + + Args: + people: + The list of Person objects returned by the service. + """ + +class AddPersonRequest(_message.Message): + """ + Request message for adding a new person to the address book. + """ + + __slots__ = ("person",) + PERSON_FIELD_NUMBER: _ClassVar[int] + person: Person + def __init__(self, person: _Optional[_Union[Person, _Mapping]] = ...) -> None: + """ + Creates a new AddPersonRequest instance. + + Args: + person: + The Person object to add. + """ + +class AddPersonResponse(_message.Message): + """ + Response message confirming the addition of a person. + """ + + __slots__ = ("person",) + PERSON_FIELD_NUMBER: _ClassVar[int] + person: Person + def __init__(self, person: _Optional[_Union[Person, _Mapping]] = ...) -> None: + """ + Creates a new AddPersonResponse instance. + + Args: + person: + The Person object that was added. + """ diff --git a/examples/plugins/proto/person_pb2_grpc.py b/examples/plugins/proto/person_pb2_grpc.py new file mode 100644 index 000000000..a091e6f88 --- /dev/null +++ b/examples/plugins/proto/person_pb2_grpc.py @@ -0,0 +1,410 @@ +# ruff: noqa: PGH004 +# ruff: noqa +""" +Client and server classes for gRPC services defined in the person.proto file. + +This module is generated by the protobuf compiler plugin and demonstrates how to use gRPC in Python. +It is intended for pedagogical purposes, showing how to implement and interact with gRPC services. + +/// note +This file is generated and should not be modified manually, except for documentation improvements. +/// +""" + +from __future__ import annotations + +from typing import Any +import grpc + +from examples.plugins.proto import person_pb2 as person__pb2 + +GRPC_GENERATED_VERSION = "1.73.1" +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + + _version_not_supported = first_version_is_lower( + GRPC_VERSION, GRPC_GENERATED_VERSION + ) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f"The grpc package installed is at version {GRPC_VERSION}," + + f" but the generated code in person_pb2_grpc.py depends on" + + f" grpcio>={GRPC_GENERATED_VERSION}." + + f" Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}" + + f" or downgrade your generated code using grpcio-tools<={GRPC_VERSION}." + ) + + +class AddressBookServiceStub(object): + """ + Stub client for the AddressBook gRPC service. + + This class provides client-side methods for interacting with the AddressBook service. + It is typically instantiated with a gRPC channel and used to make remote procedure calls. + """ + + def __init__(self, channel: grpc.Channel) -> None: + """ + Initializes the AddressBookServiceStub. + + Args: + channel: + The gRPC channel through which to make calls. + """ + self.GetPerson = channel.unary_unary( # type: ignore[call-arg] + "/person.AddressBookService/GetPerson", + request_serializer=person__pb2.GetPersonRequest.SerializeToString, + response_deserializer=person__pb2.GetPersonResponse.FromString, + _registered_method=True, + ) + self.ListPeople = channel.unary_unary( # type: ignore[call-arg] + "/person.AddressBookService/ListPeople", + request_serializer=person__pb2.ListPeopleRequest.SerializeToString, + response_deserializer=person__pb2.ListPeopleResponse.FromString, + _registered_method=True, + ) + self.AddPerson = channel.unary_unary( # type: ignore[call-arg] + "/person.AddressBookService/AddPerson", + request_serializer=person__pb2.AddPersonRequest.SerializeToString, + response_deserializer=person__pb2.AddPersonResponse.FromString, + _registered_method=True, + ) + + +class AddressBookServiceServicer(object): + """ + Server-side implementation base for the AddressBook gRPC service. + + This class should be subclassed to provide concrete implementations of the service methods. + Each method receives a request and a context, and should return the appropriate response. + """ + + def GetPerson( + self, + request: person__pb2.GetPersonRequest, + context: grpc.ServicerContext, + ) -> person__pb2.GetPersonResponse: + """ + Gets a person by their unique ID. + + Args: + request: + The request message containing the person's ID. + + context: + The context for the RPC call. + + Returns: + The response containing the person's details. + + Raises: + NotImplementedError: + If the method is not implemented. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def ListPeople( + self, + request: person__pb2.ListPeopleRequest, + context: grpc.ServicerContext, + ) -> person__pb2.ListPeopleResponse: + """ + Lists all people in the address book. + + Args: + request: + The request message (typically empty). + + context: + The context for the RPC call. + + Returns: + The response containing a list of people. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def AddPerson( + self, + request: person__pb2.AddPersonRequest, + context: grpc.ServicerContext, + ) -> person__pb2.AddPersonResponse: + """ + Adds a new person to the address book. + + Args: + request: + The request message containing the new person's details. + + context: + The context for the RPC call. + + Returns: + The response confirming the addition. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + +def add_AddressBookServiceServicer_to_server( + servicer: AddressBookServiceServicer, + server: grpc.Server, +) -> None: + """ + Registers the AddressBookServiceServicer with a gRPC server. + + Args: + servicer: + The service implementation to add to the server. + + server: + The gRPC server to which the service will be added. + """ + rpc_method_handlers: dict[str, Any] = { + "GetPerson": grpc.unary_unary_rpc_method_handler( + servicer.GetPerson, + request_deserializer=person__pb2.GetPersonRequest.FromString, + response_serializer=person__pb2.GetPersonResponse.SerializeToString, + ), + "ListPeople": grpc.unary_unary_rpc_method_handler( + servicer.ListPeople, + request_deserializer=person__pb2.ListPeopleRequest.FromString, + response_serializer=person__pb2.ListPeopleResponse.SerializeToString, + ), + "AddPerson": grpc.unary_unary_rpc_method_handler( + servicer.AddPerson, + request_deserializer=person__pb2.AddPersonRequest.FromString, + response_serializer=person__pb2.AddPersonResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + "person.AddressBookService", + rpc_method_handlers, + ) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers( # type: ignore[attr-defined] + "person.AddressBookService", + rpc_method_handlers, + ) + + +# This class is part of an EXPERIMENTAL API. +class AddressBookService(object): + """ + EXPERIMENTAL: Client for the AddressBook gRPC service using the experimental API. + + This class provides static methods for making calls to the AddressBook service using the experimental gRPC API. + """ + + @staticmethod + def GetPerson( + request: person__pb2.GetPersonRequest, + target: str, + options: tuple[Any, ...] = tuple(), + channel_credentials: grpc.ChannelCredentials | None = None, + call_credentials: grpc.CallCredentials | None = None, + insecure: bool = False, + compression: grpc.Compression | None = None, + wait_for_ready: bool | None = None, + timeout: float | None = None, + metadata: dict[str, Any] | None = None, + ) -> person__pb2.GetPersonResponse: + """ + Makes an experimental unary call to GetPerson. + + Args: + request: + The request message containing the person's ID. + + target: + The server address. + + options: + Call options. + + channel_credentials: + Channel credentials. + + call_credentials: + Call credentials. + + insecure: + Whether to use an insecure channel. + + compression: + Compression option. + + wait_for_ready: + Wait for ready option. + + timeout: + Timeout for the call. + + metadata: + Metadata for the call. + + Returns: + The response containing the person's details. + """ + return grpc.experimental.unary_unary( # type: ignore[attr-defined] + request, + target, + "/person.AddressBookService/GetPerson", + person__pb2.GetPersonRequest.SerializeToString, + person__pb2.GetPersonResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def ListPeople( + request: person__pb2.ListPeopleRequest, + target: str, + options: tuple[Any, ...] = tuple(), + channel_credentials: grpc.ChannelCredentials | None = None, + call_credentials: grpc.CallCredentials | None = None, + insecure: bool = False, + compression: grpc.Compression | None = None, + wait_for_ready: bool | None = None, + timeout: float | None = None, + metadata: dict[str, Any] | None = None, + ) -> person__pb2.ListPeopleResponse: + """ + Makes an experimental unary call to ListPeople. + + Args: + request: + The request message (typically empty). + + target: + The server address. + + options: + Call options. + + channel_credentials: + Channel credentials. + + call_credentials: + Call credentials. + + insecure: + Whether to use an insecure channel. + + compression: + Compression option. + + wait_for_ready: + Wait for ready option. + + timeout: + Timeout for the call. + + metadata: + Metadata for the call. + + + Returns: + The response containing a list of people. + """ + return grpc.experimental.unary_unary( # type: ignore[attr-defined] + request, + target, + "/person.AddressBookService/ListPeople", + person__pb2.ListPeopleRequest.SerializeToString, + person__pb2.ListPeopleResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def AddPerson( + request: person__pb2.AddPersonRequest, + target: str, + options: tuple[Any, ...] = tuple(), + channel_credentials: grpc.ChannelCredentials | None = None, + call_credentials: grpc.CallCredentials | None = None, + insecure: bool = False, + compression: grpc.Compression | None = None, + wait_for_ready: bool | None = None, + timeout: float | None = None, + metadata: dict[str, Any] | None = None, + ) -> person__pb2.AddPersonResponse: + """ + Makes an experimental unary call to AddPerson. + + Args: + request: + The request message containing the new person's details. + + target: + The server address. + + options: + Call options. + + channel_credentials: + Channel credentials. + + call_credentials: + Call credentials. + + insecure: + Whether to use an insecure channel. + + compression: + Compression option. + + wait_for_ready: + Wait for ready option. + + timeout: + Timeout for the call. + + metadata: + Metadata for the call. + + + Returns: + The response confirming the addition. + """ + return grpc.experimental.unary_unary( # type: ignore[attr-defined] + request, + target, + "/person.AddressBookService/AddPerson", + person__pb2.AddPersonRequest.SerializeToString, + person__pb2.AddPersonResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) diff --git a/examples/plugins/protobuf/__init__.py b/examples/plugins/protobuf/__init__.py new file mode 100644 index 000000000..c230692cb --- /dev/null +++ b/examples/plugins/protobuf/__init__.py @@ -0,0 +1,86 @@ +""" +Protocol Buffers Plugin Example for Pact Python v3. + +This module provides an example of how to use Pact plugins to handle different +content types in contract testing. Specifically, this example demonstrates the +use of the protobuf plugin to test interactions involving Protocol Buffers +(protobuf) message serialization. + +For detailed information about Protocol Buffers, the generated files, and the +domain model used in this example, see the [`proto`][examples.plugins.proto] +module documentation. + +## Pact and the Plugin Ecosystem + +Pact is traditionally focused on HTTP-based interactions with text-based +(primarily JSON) payloads. However, modern microservices architectures often use +various content types and transport mechanisms beyond simple text over HTTP. To +address this, Pact allows for extensibility through a plugin system that supports +different content types and protocols. + +Pact plugins extend the core functionality of Pact to support different content +types, transport protocols, and matching strategies. The plugin system allows +Pact to: + +- Handle different content types (e.g., protobuf) +- Support various transport mechanisms (e.g., gRPC) +- Provide specialized matching rules for different data formats +- Enable extensibility without modifying the core Pact library + +## This Example + +This example demonstrates how to use the Pact protobuf plugin to test +interactions involving protobuf messages sent over HTTP. It is assumed that you +have a basic understanding of Pact and Protocol Buffers. +""" + +from __future__ import annotations + +from examples.plugins.proto.person_pb2 import AddressBook, Person + + +def address_book() -> AddressBook: + """ + Create a sample address book. + + This function constructs an `AddressBook` instance containing three + `Person` instances: + + - Alice with ID 1 + - Bob with ID 2 + - Charlie with ID 3 + """ + alice = Person( + name="Alice", + id=1, + email="alice@gmail.com", + phones=[ + Person.PhoneNumber( + number="123-456-7890", type=Person.PhoneType.PHONE_TYPE_HOME + ), + Person.PhoneNumber( + number="987-654-3210", type=Person.PhoneType.PHONE_TYPE_MOBILE + ), + ], + ) + bob = Person( + name="Bob", + id=2, + email="bob@work.com", + phones=[ + Person.PhoneNumber( + number="555-555-5555", type=Person.PhoneType.PHONE_TYPE_WORK + ) + ], + ) + charlie = Person( + name="Charlie", + id=3, + email="charlie@example.com", + phones=[ + Person.PhoneNumber( + number="111-222-3333", type=Person.PhoneType.PHONE_TYPE_UNSPECIFIED + ) + ], + ) + return AddressBook(people=[alice, bob, charlie]) diff --git a/examples/plugins/protobuf/test_consumer.py b/examples/plugins/protobuf/test_consumer.py new file mode 100644 index 000000000..2dbe4f84c --- /dev/null +++ b/examples/plugins/protobuf/test_consumer.py @@ -0,0 +1,140 @@ +""" +Consumer test using Protobuf plugin with Pact Python v3. + +This module demonstrates how to write a consumer test using the Pact protobuf +plugin with Pact Python's v3 API. The protobuf plugin allows Pact to handle +Protocol Buffer messages as request and response payloads, enabling contract +testing for services that communicate using protobuf serialization. + +This example builds on the address book domain model from the [protobuf.dev +tutorial](https://protobuf.dev/getting-started/pythontutorial/) and shows how to +test a consumer that retrieves Person data from a provider service using +protobuf-serialized messages over HTTP. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import requests + +from examples.plugins.proto.person_pb2 import Person +from examples.plugins.protobuf import address_book +from pact import Pact + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + +@pytest.fixture +def pact(pacts_path: Path) -> Generator[Pact, None, None]: + """ + Set up the Pact fixture with protobuf plugin. + + This fixture configures a Pact instance for consumer testing with the + protobuf plugin enabled. The protobuf plugin allows Pact to understand and + handle Protocol Buffer message serialization for both request and response + payloads. + + The fixture uses the V4 specification which provides full support for + plugins and content type matching. + + Yields: + The configured Pact instance for protobuf consumer tests. + """ + pact = ( + Pact("protobuf_consumer", "protobuf_provider") + .with_specification("V4") + .using_plugin("protobuf") + ) + yield pact + pact.write_file(pacts_path) + + +def test_get_person_by_id(pact: Pact) -> None: + """ + Test retrieving a Person by ID using protobuf serialization. + + This test defines the expected interaction for a GET request to retrieve a + specific person from the address book. The response will be a protobuf- + serialized Person message. + + The test demonstrates: + + - Using the protobuf plugin to handle binary protobuf content + - Matching on protobuf message structure and content + - Deserializing the protobuf response for validation + + The provider state ensures that a person with ID 1 exists in the system, + corresponding to Alice from our sample address book. + """ + sample_address_book = address_book() + alice = sample_address_book.people[0] + expected_protobuf_data = alice.SerializeToString() + + ( + pact + .upon_receiving("a request to get person by ID") + .given("person with the given ID exists", user_id=1) + .with_request("GET", "/person/1") + .will_respond_with(200) + .with_header("Content-Type", "application/x-protobuf") + .with_binary_body(expected_protobuf_data, "application/x-protobuf") + ) + + with pact.serve() as srv: + # NOTE: We use the `requests` library here to demonstrate the + # principles; however, in a real-world scenario, you would be using the + # actual client code that interacts with the provider service. This + # ensures that you are testing the consumer's behaviour. + response = requests.get(f"{srv.url}/person/1", timeout=5) + + # Verify response + assert response.status_code == 200 + assert response.headers["Content-Type"] == "application/x-protobuf" + + # Deserialize the protobuf response and then verify its content + person = Person() + person.ParseFromString(response.content) + + assert person.id == 1 + assert person.name == "Alice" + assert person.email == "alice@gmail.com" + assert len(person.phones) == 2 + + assert person.phones[0].number == "123-456-7890" + assert person.phones[0].type == Person.PhoneType.PHONE_TYPE_HOME + assert person.phones[1].number == "987-654-3210" + assert person.phones[1].type == Person.PhoneType.PHONE_TYPE_MOBILE + + +def test_get_nonexistent_person(pact: Pact) -> None: + """ + Test retrieving a non-existent Person by ID. + + This test verifies the provider's behavior when requesting a person that + doesn't exist in the address book. The provider should return a 404 status + code with an appropriate error message as a JSON response. + """ + ( + pact + .upon_receiving("a request to get non-existent person") + .given("person with the given ID does not exist", user_id=999) + .with_request("GET", "/person/999") + .will_respond_with(404) + .with_header("Content-Type", "application/json") + .with_body({"detail": "Person not found"}) + ) + + with pact.serve() as srv: + # NOTE: Again, we use the `requests` library to simulate the consumer's + # request to the provider service. A real-world consumer would instead + # use its own client and check that the appropriate error message is + # raised and/or handled. + response = requests.get(f"{srv.url}/person/999", timeout=5) + + assert response.status_code == 404 + assert response.headers["Content-Type"] == "application/json" + assert response.json() == {"detail": "Person not found"} diff --git a/examples/plugins/protobuf/test_provider.py b/examples/plugins/protobuf/test_provider.py new file mode 100644 index 000000000..f42e49faa --- /dev/null +++ b/examples/plugins/protobuf/test_provider.py @@ -0,0 +1,235 @@ +""" +Provider test using Protobuf plugin with Pact Python v3. + +This module demonstrates how to write a provider test using the Pact protobuf +plugin with Pact Python's v3 API. The provider test verifies that the provider +service correctly handles the contract defined by the consumer test. + +The provider test runs the actual provider service and uses Pact to replay the +consumer's interactions against the provider, verifying that the provider +responds correctly with protobuf-serialized messages. + +This example shows how to: + +- Set up a FastAPI provider that handles protobuf responses +- Use the Pact Verifier with the protobuf plugin +- Handle provider states for setting up test data +- Verify protobuf serialization in the provider responses +""" + +from __future__ import annotations + +import contextlib +import time +from threading import Thread +from typing import TYPE_CHECKING, Any, Literal + +import pytest +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.responses import Response +from yarl import URL + +from examples.plugins.proto.person_pb2 import AddressBook +from examples.plugins.protobuf import address_book +from pact import Verifier + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + +PROVIDER_URL = URL("http://localhost:8001") + +# Global variable to hold our mock address book data +# In a real application, this would be a database or other data store +MOCK_ADDRESS_BOOK: AddressBook | None = None + + +class Server(uvicorn.Server): + """ + Custom server class to run the FastAPI server in a separate thread. + + This allows the provider test to run the FastAPI server in the background + while Pact verifies the interactions against it. + """ + + def install_signal_handlers(self) -> None: + """ + Prevent the server from installing signal handlers. + + This is required to run the FastAPI server in a separate process. + """ + + @contextlib.contextmanager + def run_in_thread(self) -> Generator[str, None, None]: + """ + Run the FastAPI server in a separate thread. + + Yields: + The URL of the running server. + """ + thread = Thread(target=self.run) + thread.start() + try: + while not self.started: + time.sleep(0.01) + yield f"http://{self.config.host}:{self.config.port}" + finally: + self.should_exit = True + thread.join() + + +app = FastAPI(title="Protobuf Address Book API") +""" +FastAPI application + +This application serves as the provider for the address book service, +handling requests to retrieve person data by ID. It uses Protocol Buffers for +serialization of the response data. + +This code would typically be in a separate module within your application, but +for the sake of this example, it is included directly within the test module. +""" + + +@app.get("/person/{person_id}") +async def get_person(person_id: int) -> Response: + """ + Get a person by ID, returning protobuf-serialized data. + + Args: + person_id: The ID of the person to retrieve. + + Returns: + Response containing protobuf-serialized Person data. + + Raises: + HTTPException: If person is not found. + """ + if MOCK_ADDRESS_BOOK is None: + raise HTTPException(status_code=404, detail="Person not found") + + # Find person by ID + for person in MOCK_ADDRESS_BOOK.people: + if person.id == person_id: + # Serialize person to protobuf bytes + protobuf_data = person.SerializeToString() + return Response( + content=protobuf_data, + media_type="application/x-protobuf", + ) + + raise HTTPException(status_code=404, detail="Person not found") + + +@pytest.fixture(scope="session") +def server() -> Generator[str, None, None]: + """ + Fixture to start the FastAPI server for testing. + + Yields: + The URL of the running server. + """ + assert PROVIDER_URL.host is not None + assert PROVIDER_URL.port is not None + server = Server( + uvicorn.Config( + app, + host=PROVIDER_URL.host, + port=PROVIDER_URL.port, + ) + ) + with server.run_in_thread() as url: + yield url + + +def test_provider(server: str, pacts_path: Path) -> None: + """ + Test the protobuf provider against the consumer contract. + + This test uses the Pact Verifier to replay the consumer's interactions + against the running provider service. It verifies that the provider + correctly handles protobuf serialization and responds appropriately + to both successful and error scenarios. + + The test: + + 1. Configures the Verifier with the protobuf plugin + 2. Points the verifier to the pact file generated by the consumer + 3. Sets up state handlers to prepare test data + 4. Verifies all interactions match the contract + """ + pact_file = pacts_path / "protobuf_consumer-protobuf_provider.json" + + verifier = ( + Verifier("protobuf_provider") + .add_transport(url=server) + .add_source(pact_file) + .state_handler( + { + "person with the given ID exists": state_person_exists, + "person with the given ID does not exist": state_person_doesnt_exist, + }, + teardown=True, + ) + ) + + verifier.verify() + + +def state_person_exists( + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None = None, +) -> None: + """ + Handle provider state for when a person with the given ID exists. + + Args: + action: + Either "setup" or "teardown". + + parameters: + Dictionary containing user_id key. + """ + global MOCK_ADDRESS_BOOK # noqa: PLW0603 + + if action == "setup": + MOCK_ADDRESS_BOOK = address_book() + if user_id := parameters.get("user_id") if parameters else None: + assert any(person.id == user_id for person in MOCK_ADDRESS_BOOK.people), ( + f"Person with ID {user_id} does not exist in address book" + ) + else: + msg = "User ID not provided" + raise AssertionError(msg) + elif action == "teardown": + MOCK_ADDRESS_BOOK = None + + +def state_person_doesnt_exist( + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None = None, +) -> None: + """ + Handle provider state for when a person with the given ID doesn't exist. + + Args: + action: + Either "setup" or "teardown". + + parameters: + Dictionary containing user_id key. + """ + global MOCK_ADDRESS_BOOK # noqa: PLW0603 + + if action == "setup": + MOCK_ADDRESS_BOOK = AddressBook() + if user_id := parameters.get("user_id") if parameters else None: + assert not any( + person.id == user_id for person in MOCK_ADDRESS_BOOK.people + ), f"Person with ID {user_id} should not exist in address book" + else: + msg = "User ID not provided" + raise AssertionError(msg) + elif action == "teardown": + MOCK_ADDRESS_BOOK = None diff --git a/examples/fastapi_provider/tests/__init__.py b/examples/v2/__init__.py similarity index 100% rename from examples/fastapi_provider/tests/__init__.py rename to examples/v2/__init__.py diff --git a/examples/v2/src/__init__.py b/examples/v2/src/__init__.py new file mode 100644 index 000000000..87b9d2a43 --- /dev/null +++ b/examples/v2/src/__init__.py @@ -0,0 +1,12 @@ +""" +Example Client Code. + +This module defines a simple consumer and a couple of implementation of simple +providers. The general premise here is that the consumers will be fetching user +information from the providers. + +The development of the consumer and provider sides would typically be done in +separate teams (and likely different languages). Within the Pact framework, the +consumer side is the one which defines the contract and the provider side is the +one which must satisfy the contract. +""" diff --git a/examples/v2/src/consumer.py b/examples/v2/src/consumer.py new file mode 100644 index 000000000..966a80955 --- /dev/null +++ b/examples/v2/src/consumer.py @@ -0,0 +1,162 @@ +""" +Simple Consumer Implementation. + +This modules defines a simple +[consumer](https://docs.pact.io/getting_started/terminology#service-consumer) +which will be tested with Pact in the [consumer +test][examples.tests.test_00_consumer]. As Pact is a consumer-driven framework, +the consumer defines the interactions which the provider must then satisfy. + +The consumer is the application which makes requests to another service (the +provider) and receives a response to process. In this example, we have a simple +[`User`][examples.v2.src.consumer.User] class and the consumer fetches a user's +information from a HTTP endpoint. + +This also showcases how Pact tests differ from merely testing adherence to an +OpenAPI specification. The Pact tests are more concerned with the practical use +of the API, rather than the formally defined specification. So you will see +below that as far as this consumer is concerned, the only information needed +from the provider is the user's ID, name, and creation date. This is despite the +provider having additional fields in the response. + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +import requests + + +@dataclass() +class User: + """User data class.""" + + id: int + name: str + created_on: datetime + + def __post_init__(self) -> None: + """ + Validate the User data. + + This performs the following checks: + + - The name cannot be empty + - The id must be a positive integer + + Raises: + ValueError: If any of the above checks fail. + """ + if not self.name: + msg = "User must have a name" + raise ValueError(msg) + + if self.id < 0: + msg = "User ID must be a positive integer" + raise ValueError(msg) + + def __repr__(self) -> str: + """Return the user's name.""" + return f"User({self.id}:{self.name})" + + +class UserConsumer: + """ + Example consumer. + + This class defines a simple consumer which will interact with a provider + over HTTP to fetch a user's information, and then return an instance of the + `User` class. + """ + + def __init__(self, base_uri: str) -> None: + """ + Initialise the consumer. + + Args: + base_uri: The uri of the provider + """ + self.base_uri = base_uri + + def get_user(self, user_id: int) -> User: + """ + Fetch a user by ID from the server. + + Args: + user_id: The ID of the user to fetch. + + Returns: + The user if found. + + In all other cases, an error dictionary is returned with the key + `error` and the value as the error message. + + Raises: + requests.HTTPError: If the server returns a non-200 response. + """ + uri = f"{self.base_uri}/users/{user_id}" + response = requests.get(uri, timeout=5) + response.raise_for_status() + data: dict[str, Any] = response.json() + # Python < 3.11 don't support ISO 8601 offsets without a colon + if sys.version_info < (3, 11) and data["created_on"][-4:].isdigit(): + data["created_on"] = data["created_on"][:-2] + ":" + data["created_on"][-2:] + return User( + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), + ) + + def create_user( + self, + *, + name: str, + ) -> User: + """ + Create a new user on the server. + + Args: + name: The name of the user to create. + + Returns: + The user, if successfully created. + + Raises: + requests.HTTPError: If the server returns a non-200 response. + """ + uri = f"{self.base_uri}/users/" + response = requests.post(uri, json={"name": name}, timeout=5) + response.raise_for_status() + data: dict[str, Any] = response.json() + # Python < 3.11 don't support ISO 8601 offsets without a colon + if sys.version_info < (3, 11) and data["created_on"][-4:].isdigit(): + data["created_on"] = data["created_on"][:-2] + ":" + data["created_on"][-2:] + return User( + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), + ) + + def delete_user(self, uid: int | User) -> None: + """ + Delete a user by ID from the server. + + Args: + uid: The user ID or user object to delete. + + Raises: + requests.HTTPError: If the server returns a non-200 response. + """ + if isinstance(uid, User): + uid = uid.id + + uri = f"{self.base_uri}/users/{uid}" + response = requests.delete(uri, timeout=5) + response.raise_for_status() diff --git a/examples/v2/src/message.py b/examples/v2/src/message.py new file mode 100644 index 000000000..aa4f33333 --- /dev/null +++ b/examples/v2/src/message.py @@ -0,0 +1,123 @@ +""" +Handler for non-HTTP interactions. + +This module implements a very basic handler to handle JSON payloads which might +be sent through a messaging system. Unlike a HTTP interaction, the handler is +solely responsible for processing the message, and does not necessarily need to +send a response. This specific example handles file system events. + +Due to the broad range of possible technologies underpinning message systems +(e.g., Kafka, RabbitMQ, SQS, SNS, etc.), Pact's implementation is agnostic to +the transport mechanism. Instead, Pact Python v3 allows to provide a simple +function (or mapping of functions) to produce messages. Under the hood, Pact +uses HTTP to communicate + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +class Filesystem: + """ + Filesystem interface. + + In practice, the handler would process messages and perform some actions on + other systems, whether that be a database, a filesystem, or some other + service. This capability would typically be offered by some library; + however, when running tests, we typically wish to avoid actually interacting + with this external service. + + In order to avoid side effects while testing, the test setup should mock out + the calls to the external service. + + This class provides a simple dummy filesystem interface (which evidently + would fail if actually used), and serves to demonstrate how to mock out + external services when testing. + """ + + def __init__(self) -> None: + """Initialize the filesystem connection.""" + + def write(self, _file: str, _contents: str) -> None: + """Write contents to a file.""" + raise NotImplementedError + + def read(self, file: str) -> str: + """Read contents from a file.""" + raise NotImplementedError + + +class Handler: + """ + Message queue handler. + + This class is responsible for handling messages from the queue. + """ + + def __init__(self) -> None: + """ + Initialize the handler. + + This ensures the underlying filesystem is ready to be used. + """ + self.fs = Filesystem() + + def process(self, event: dict[str, Any]) -> str | None: + """ + Process an event from the queue. + + The event is expected to be a dictionary with the following mandatory + keys: + + - `action`: The action to be performed, either `READ` or `WRITE`. + - `path`: The path to the file to be read or written. + + The event may also contain an optional `contents` key, which is the + contents to be written to the file. If the `contents` key is not + present, an empty file will be written. + """ + self.validate_event(event) + + if event["action"] == "WRITE": + self.fs.write(event["path"], event.get("contents", "")) + return None + if event["action"] == "READ": + return self.fs.read(event["path"]) + + msg = f"Invalid action: {event['action']!r}" + raise ValueError(msg) + + @staticmethod + def validate_event(event: dict[str, Any] | Any) -> None: # noqa: ANN401 + """ + Validates the event received from the queue. + + The event is expected to be a dictionary with the following mandatory + keys: + + - `action`: The action to be performed, either `READ` or `WRITe`. + - `path`: The path to the file to be read or written. + """ + if not isinstance(event, dict): + msg = "Event must be a dictionary." + raise TypeError(msg) + if "action" not in event: + msg = "Event must contain an 'action' key." + raise ValueError(msg) + if "path" not in event: + msg = "Event must contain a 'path' key." + raise ValueError(msg) + if event["action"] not in ["READ", "WRITE"]: + msg = "Event must contain a valid 'action' key." + raise ValueError(msg) + try: + Path(event["path"]) + except TypeError as err: + msg = "Event must contain a valid 'path' key." + raise ValueError(msg) from err diff --git a/examples/v2/src/message_producer.py b/examples/v2/src/message_producer.py new file mode 100644 index 000000000..fe8efcb26 --- /dev/null +++ b/examples/v2/src/message_producer.py @@ -0,0 +1,110 @@ +""" +Message producer for non-HTTP interactions. + +This modules implements a very basic message producer which could +send to an eventing system, such as Kafka, or a message queue. + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. +""" + +from __future__ import annotations + +import enum +import json +from typing import Literal, NamedTuple + + +class FileSystemAction(enum.Enum): + """ + Represents a file system action. + """ + + READ = "READ" + WRITE = "WRITE" + + +class FileSystemEvent(NamedTuple): + """ + Represents a file system event. + """ + + action: Literal[FileSystemAction.READ, FileSystemAction.WRITE] + path: str + contents: str | None + + +class MockMessageQueue: + """ + A mock message queue. + """ + + def __init__(self) -> None: + """ + Initialize the message queue. + """ + self.messages: list[str] = [] + + def send(self, message: str) -> None: + """ + Send a message to the queue. + + Args: + message: The message to send. + """ + self.messages.append(message) + + +class FileSystemMessageProducer: + """ + A message producer for file system events. + """ + + def __init__(self) -> None: + """ + Initialize the message producer. + """ + self.queue = MockMessageQueue() + + def send_to_queue(self, message: FileSystemEvent) -> None: + """ + Send a message to a message queue. + + :param message: The message to send. + """ + self.queue.send( + json.dumps({ + "action": message.action.value, + "path": message.path, + "contents": message.contents, + }) + ) + + def send_write_event(self, filename: str, contents: str) -> None: + """ + Send a write event to a message queue. + + Args: + filename: The name of the file. + contents: The contents of the file. + """ + message = FileSystemEvent( + action=FileSystemAction.WRITE, + path=filename, + contents=contents, + ) + self.send_to_queue(message) + + def send_read_event(self, filename: str) -> None: + """ + Send a read event to a message queue. + + :param filename: The name of the file. + """ + message = FileSystemEvent( + action=FileSystemAction.READ, + path=filename, + contents=None, + ) + self.send_to_queue(message) diff --git a/examples/fastapi_provider/tests/provider/__init__.py b/examples/v2/tests/__init__.py similarity index 100% rename from examples/fastapi_provider/tests/provider/__init__.py rename to examples/v2/tests/__init__.py diff --git a/examples/v2/tests/test_02_message_consumer.py b/examples/v2/tests/test_02_message_consumer.py new file mode 100644 index 000000000..cd79253a7 --- /dev/null +++ b/examples/v2/tests/test_02_message_consumer.py @@ -0,0 +1,139 @@ +""" +Test Message Pact consumer. + +Pact was originally designed for HTTP interactions involving a request and a +response. Message Pact is an addition to Pact that allows for testing of +non-HTTP interactions, such as message queues. This example demonstrates how to +use Message Pact to test whether a consumer can handle the messages it. Due to +the large number of possible transports, Message Pact does not provide a mock +provider and the tests only verifies the messages. + +A note on terminology, the _consumer_ for Message Pact is the system that +receives the message, and the _provider_ is the system that sends the message. +Pact is still consumer-driven, and the consumer defines the expected messages it +will receive from the provider. When the provider is being verified, Pact +ensures that the provider sends the expected messages. + +In this example, Pact simply ensures that the consumer is capable of processing +the message. The consumer need not send back a message, and any sideffects of +the message must be verified separately (such as through `assert` statements or +as part of the usual unit testing suite). + +> :warning: There is currently a bug whereby the `given` and +`expects_to_receive` have swapped meanings compared to the reference +implementation. This will be addressed in a future release. + +A good resource for understanding the message pact testing can be found [in the +Pact +documentation](https://docs.pact.io/getting_started/how_pact_works#non-http-testing-message-pact). +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock + +import pytest + +from examples.v2.src.message import Handler +from pact.v2 import MessageConsumer, MessagePact, Provider + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + from yarl import URL + +log = logging.getLogger(__name__) + + +@pytest.fixture(scope="module") +def pact(broker: URL, pacts_path: Path) -> Generator[MessagePact, Any, None]: + """ + Set up Message Pact Consumer. + + This fixtures sets up the Message Pact consumer and the pact it has with a + provider. The consumer defines the expected messages it will receive from + the provider, and the Python test suite verifies that the correct actions + are taken. + + For each interaction, the consumer defines the following: + + ```python + ( + pact.given("a request to write test.txt") + .expects_to_receive("empty filesystem") + .with_content(msg) + .with_metadata({"Content-Type": "application/json"}) + ) + + NOTE: There is currently a bug whereby the `given` and `expects_to_receive` + have swapped meanings. This will be addressed in a future release. + ``` + """ + consumer = MessageConsumer("MessageConsumer") + pact = consumer.has_pact_with( + Provider("MessageProvider"), + pact_dir=pacts_path, + publish_to_broker=True, + # Broker configuration + broker_base_url=str(broker), + broker_username=broker.user, + broker_password=broker.password, + ) + with pact: + yield pact + + +@pytest.fixture +def handler() -> Handler: + """ + Fixture for the Handler. + + This fixture mocks the filesystem calls in the handler, so that we can + verify that the handler is calling the filesystem correctly. + """ + handler = Handler() + handler.fs = MagicMock() + handler.fs.write.return_value = None + handler.fs.read.return_value = "Hello world!" + return handler + + +def test_write_file(pact: MessagePact, handler: Handler) -> None: + """ + Test write file. + + This test will be run against the mock provider. The mock provider will + expect to receive a request to write a file, and will respond with a 200 + status code. + """ + msg = {"action": "WRITE", "path": "test.txt", "contents": "Hello world!"} + ( + pact.given("a request to write test.txt") + .expects_to_receive("empty filesystem") + .with_content(msg) + .with_metadata({"Content-Type": "application/json"}) + ) + + result = handler.process(msg) + handler.fs.write.assert_called_once_with( # type: ignore[attr-defined] + "test.txt", + "Hello world!", + ) + assert result is None + + +def test_read_file(pact: MessagePact, handler: Handler) -> None: + msg = {"action": "READ", "path": "test.txt"} + ( + pact.given("a request to read test.txt") + .expects_to_receive("test.txt exists") + .with_content(msg) + .with_metadata({"Content-Type": "application/json"}) + ) + + result = handler.process(msg) + handler.fs.read.assert_called_once_with("test.txt") # type: ignore[attr-defined] + assert result == "Hello world!" diff --git a/examples/v2/tests/test_03_message_provider.py b/examples/v2/tests/test_03_message_provider.py new file mode 100644 index 000000000..644b0ae5c --- /dev/null +++ b/examples/v2/tests/test_03_message_provider.py @@ -0,0 +1,76 @@ +""" +Test Message Pact provider. + +Pact was originally designed for HTTP interactions involving a request and a +response. Message Pact is an addition to Pact that allows for testing of +non-HTTP interactions, such as message queues. This example demonstrates how to +use Message Pact to test whether a consumer can handle the messages it. Due to +the large number of possible transports, Message Pact does not provide a mock +provider and the tests only verifies the messages. + +A note on terminology, the _consumer_ for Message Pact is the system that +receives the message, and the _provider_ is the system that sends the message. +Pact is still consumer-driven, and the consumer defines the expected messages it +will receive from the provider. When the provider is being verified, Pact +ensures that the provider sends the expected messages. + +The below example verifies that the provider sends the expected messages. The +consumer need not send back a message, and any sideffects of the message must +be verified on the consumer side. + +A good resource for understanding the message pact testing can be found [in the +Pact +documentation](https://docs.pact.io/getting_started/how_pact_works#non-http-testing-message-pact). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from flask import Flask +from pact.v2 import MessageProvider + +if TYPE_CHECKING: + from yarl import URL + +app = Flask(__name__) +PACT_DIR = (Path(__file__).parent / "pacts").resolve() + + +def generate_write_message() -> dict[str, str]: + return { + "action": "WRITE", + "path": "test.txt", + "contents": "Hello world!", + } + + +def generate_read_message() -> dict[str, str]: + return { + "action": "READ", + "path": "test.txt", + } + + +def test_verify(broker: URL) -> None: + provider = MessageProvider( + provider="MessageProvider", + consumer="MessageConsumer", + pact_dir=str(PACT_DIR), + message_providers={ + "a request to write test.txt": generate_write_message, + "a request to read test.txt": generate_read_message, + }, + ) + + with provider: + provider.verify_with_broker( + broker_url=str(broker), + # Despite the auth being set in the broker URL, we still need to pass + # the username and password to the verify_with_broker method. + broker_username=broker.user, + broker_password=broker.password, + publish_version="0.0.0", + publish_verification_results=True, + ) diff --git a/examples/message/tests/consumer/__init__.py b/examples/v2/tests/v3/__init__.py similarity index 100% rename from examples/message/tests/consumer/__init__.py rename to examples/v2/tests/v3/__init__.py diff --git a/examples/v2/tests/v3/test_02_message_consumer.py b/examples/v2/tests/v3/test_02_message_consumer.py new file mode 100644 index 000000000..6e5bd1dcf --- /dev/null +++ b/examples/v2/tests/v3/test_02_message_consumer.py @@ -0,0 +1,191 @@ +""" +Consumer test of example message handler using the v3 API. + +This test will create a pact between the message handler +and the message provider. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, +) +from unittest.mock import MagicMock + +import pytest + +from examples.v2.src.message import Handler +from pact.pact import Pact + +if TYPE_CHECKING: + from collections.abc import Callable, Generator + + +log = logging.getLogger(__name__) + + +@pytest.fixture(scope="module") +def pact(pacts_path: Path) -> Generator[Pact, None, None]: + """ + Set up Message Pact Consumer. + + This fixtures sets up the Message Pact consumer and the pact it has with a + provider. The consumer defines the expected messages it will receive from + the provider, and the Python test suite verifies that the correct actions + are taken. + + The verify method takes a function as an argument. This function + will be called with one or two arguments - the value of `with_body` and + the contents of `with_metadata` if provided. + + If the function under test does not take those parameters, you can create + a wrapper function to convert the pact parameters into the values + expected by your function. + + + For each interaction, the consumer defines the following: + + ```python + ( + pact = Pact("consumer name", "provider name") + processed_messages: list[MessagePact.MessagePactResult] = pact \ + .with_specification("V3") + .upon_receiving("a request", "Async") \ + .given("a request to write test.txt") \ + .with_body(msg) \ + .with_metadata({"Content-Type": "application/json"}) + .verify(pact_handler) + ) + + ``` + """ + pact = Pact("v3_message_consumer", "v3_message_provider") + log.info("Creating Message Pact with V3 specification") + yield pact.with_specification("V3") + pact.write_file(pacts_path, overwrite=True) + + +@pytest.fixture +def handler() -> Handler: + """ + Fixture for the Handler. + + This fixture mocks the filesystem calls in the handler, so that we can + verify that the handler is calling the filesystem correctly. + """ + handler = Handler() + handler.fs = MagicMock() + handler.fs.write.return_value = None + handler.fs.read.return_value = "Hello world!" + return handler + + +@pytest.fixture +def verifier( + handler: Handler, +) -> Generator[Callable[[str | bytes | None, dict[str, Any]], None], Any, None]: + """ + Verifier function for the Pact. + + This function is passed to the `verify` method of the Pact object. It is + responsible for taking in the messages (along with the context/metadata) + and ensuring that the consumer is able to process the message correctly. + + In our case, we deserialize the message and pass it to the (pre-mocked) + handler for processing. We then verify that the underlying filesystem + calls were made as expected. + """ + assert isinstance(handler.fs, MagicMock), "Handler filesystem not mocked" + + def _verifier(msg: str | bytes | None, context: dict[str, Any]) -> None: + assert msg is not None, "Message is None" + data = json.loads(msg) + log.info( + "Processing message: ", + extra={"input": msg, "processed_message": data, "context": context}, + ) + handler.process(data) + + yield _verifier + + assert handler.fs.mock_calls, "Handler did not call the filesystem" + + +def test_async_message_handler_write( + pact: Pact, + handler: Handler, + verifier: Callable[[str | bytes | None, dict[str, Any]], None], +) -> None: + """ + Create a pact between the message handler and the message provider. + """ + assert isinstance(handler.fs, MagicMock), "Handler filesystem not mocked" + + ( + pact.upon_receiving("a write request", "Async") + .given("a request to write test.txt") + .with_body( + json.dumps({ + "action": "WRITE", + "path": "my_file.txt", + "contents": "Hello, world!", + }), + "application/json", + ) + .with_matching_rules( + { + "body": { + "$.path": { + "combine": "AND", + "matchers": [ + {"match": "type"}, + ], + } + } + }, + "Response", + ) + ) + pact.verify(verifier, "Async") + + handler.fs.write.assert_called_once_with("my_file.txt", "Hello, world!") + + +def test_async_message_handler_read( + pact: Pact, + handler: Handler, + verifier: Callable[[str | bytes | None, dict[str, Any]], None], +) -> None: + """ + Create a pact between the message handler and the message provider. + """ + assert isinstance(handler.fs, MagicMock), "Handler filesystem not mocked" + + ( + pact.upon_receiving("a read request", "Async") + .given("a request to read test.txt") + .with_body( + json.dumps({ + "action": "READ", + "path": "my_file.txt", + }), + "application/json", + ) + .with_matching_rules({ + "body": { + "$.path": { + "combine": "AND", + "matchers": [ + {"match": "type"}, + ], + }, + } + }) + ) + pact.verify(verifier, "Async") + + handler.fs.read.assert_called_once_with("my_file.txt") diff --git a/examples/v2/tests/v3/test_03_message_provider.py b/examples/v2/tests/v3/test_03_message_provider.py new file mode 100644 index 000000000..3d0abcb4d --- /dev/null +++ b/examples/v2/tests/v3/test_03_message_provider.py @@ -0,0 +1,64 @@ +""" +Producer test of example message. + +This test will read a pact between the message handler and the message provider +and then validate the pact against the provider. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +from examples.v2.src.message_producer import FileSystemMessageProducer +from pact import Verifier +from pact.types import Message + +PACT_DIR = (Path(__file__).parent.parent.parent / "pacts").resolve() + +RESPONSES: dict[str, dict[str, str]] = { + "a request to write test.txt": { + "function_name": "send_write_event", + }, + "a request to read test.txt": { + "function_name": "send_read_event", + }, +} + + +def message_producer(message: str, metadata: dict[str, Any] | None) -> Message: # noqa: ARG001 + """ + Function to produce a message for the provider. + + This specific implementation is rather simple as it returns static content. + In fact, a straight mapping of the message names to the expected responses + could be given to the message handler directly. However, this function is + provided to demonstrate the capability of the message handler to be very + generic. + + Args: + message: + The message name. + + metadata: + Any metadata associated with the message which can be used to + determine the response. + """ + producer = FileSystemMessageProducer() + producer.queue = MagicMock() + + return Message( + contents=json.dumps(RESPONSES[message]).encode("utf-8"), + content_type="application/json", + metadata=None, + ) + + +def test_producer() -> None: + """ + Test the message producer. + """ + verifier = Verifier("provider").message_handler(message_producer) + verifier.verify() diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000..3ddc1171f --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,183 @@ +--- +site_name: Pact Python +site_url: https://pact-foundation.github.io/pact-python/ + +repo_name: pact-foundation/pact-python +repo_url: https://github.com/pact-foundation/pact-python + +edit_uri: edit/develop/docs + +hooks: + - docs/scripts/rewrite-docs-links.py + +plugins: + - gen-files: + scripts: + - docs/scripts/markdown.py + - docs/scripts/python.py + # - docs/scripts/other.py + - search + - literate-nav: + nav_file: SUMMARY.md + - section-index + - gh-admonitions + - mkdocstrings: + default_handler: python + enable_inventory: true + handlers: + python: + inventories: + - https://docs.aiohttp.org/en/stable/objects.inv + - https://docs.pydantic.dev/latest/objects.inv + - https://docs.python.org/3/objects.inv + - https://fastapi.tiangolo.com/objects.inv + - https://flask.palletsprojects.com/en/stable/objects.inv + - https://googleapis.dev/python/protobuf/latest/objects.inv + - https://grpc.github.io/grpc/python/objects.inv + - https://requests.readthedocs.io/en/latest/objects.inv + options: + # General + allow_inspection: true + extensions: + - dataclasses + - griffe_generics + # - griffe_inherited_method_crossrefs # Waiting on upstream fix + - griffe_pydantic + - griffe_warnings_deprecated + show_inheritance_diagram: true + show_source: true + # Headings + heading_level: 2 + show_category_heading: true + show_symbol_type_heading: true + show_symbol_type_toc: true + # Members + filters: + - '!^_' + - '!^__' + # Docstrings + docstring_style: google + docstring_options: + ignore_init_summary: true + docstring_section_style: spacy + merge_init_into_class: true + # relative_crossrefs: true + scoped_crossrefs: true + show_if_no_docstring: true + # Signature + annotations_path: brief + modernize_annotations: true + overloads_only: true + show_signature_annotations: true + show_signature_type_parameters: true + signature_crossrefs: true + - llmstxt: + full_output: llms-full.txt + sections: + Usage documentation: + - api/*.md + - api/**/*.md + Examples: + - examples/*.md + - social + - blog: + blog_toc: true + post_excerpt: required + +markdown_extensions: + # Python Markdown + - abbr + - attr_list + - footnotes + - meta + - md_in_html + - tables + - toc: + permalink: true + + # Python Markdown Extensions + - pymdownx.arithmatex: + generic: true + - pymdownx.betterem: + smart_enable: all + - pymdownx.blocks.admonition + - pymdownx.blocks.definition + - pymdownx.blocks.details + - pymdownx.blocks.html: + - pymdownx.blocks.tab: + alternate_style: true + - pymdownx.caret + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.mark + - pymdownx.pathconverter: + absolute: false + - pymdownx.smartsymbols + - pymdownx.snippets + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tilde + +copyright: Copyright © 2025 Pact Foundation + +theme: + name: material + + icon: + repo: fontawesome/brands/github + + features: + - content.action.edit + - content.action.view + - content.code.annotate + - content.code.copy + - content.code.select + - content.tooltips + - content.tabs.link + - navigation.indexes + - navigation.instant + - navigation.instant.progress + - navigation.sections + - navigation.tabs + - navigation.top + - navigation.tracking + - navigation.footer + - search.highlight + - search.share + - search.suggest + - toc.follow + + palette: + - media: (prefers-color-scheme) + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: '(prefers-color-scheme: light)' + scheme: default + primary: cyan + accent: cyan + toggle: + icon: material/weather-sunny + name: Switch to dark mode + - media: '(prefers-color-scheme: dark)' + scheme: slate + primary: cyan + accent: cyan + toggle: + icon: material/weather-night + name: Switch to system preference + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/pact-foundation/pact-python diff --git a/pact-python-cli/.gitignore b/pact-python-cli/.gitignore new file mode 100644 index 000000000..ff74a364b --- /dev/null +++ b/pact-python-cli/.gitignore @@ -0,0 +1,3 @@ +src/pact_cli/bin +src/pact_cli/data +src/pact_cli/lib diff --git a/pact-python-cli/CHANGELOG.md b/pact-python-cli/CHANGELOG.md new file mode 100644 index 000000000..a5e13b758 --- /dev/null +++ b/pact-python-cli/CHANGELOG.md @@ -0,0 +1,80 @@ +# Pact Python CLI Changelog + +All notable changes to this project will be documented in this file. + +Note that this _only_ includes changes to the Python re-packaging of the Pact CLI. For changes to the Pact CLI itself, see the [Pact CLI changelog](https://github.com/pact-foundation/pact-standalone/blob/master/CHANGELOG.md). + + + + + +## [pact-python-cli/2.6.0.0] _2026-04-16_ + +### 🐛 Bug Fixes + +- Bundle both ruby and rust CLIs + +### 📚 Documentation + +- Update changelog for pact-python-cli/2.5.7.0 + +### ⚙️ Miscellaneous Tasks + +- Remove versioningit, switch to static version in pyproject.toml +- Minor update to cliff config +- Replace taplo with tombi +- Allow windows arm cli builds to proceed + +### Contributors + +- @JP-Ellis + +## [pact-python-cli/2.5.7.0] _2025-12-10_ + +### 🚀 Features + +- _(ffi)_ Add standalone ffi package +- _(v3)_ [**breaking**] Remove pact.v3.ffi module + > `pact.v3.ffi` is removed, and to be replaced by `pact_ffi`. That is, `pact.v3.ffi.$fn` should be replaced by `pact_ffi.$fn`. +- _(ffi)_ Upgrade lib to 0.4.28 + +### 🐛 Bug Fixes + +- Allow none in with_metadata +- _(ffi)_ Make version dynamic + +### 📚 Documentation + +- Update changelog for pact-python-ffi/0.4.22.0 +- _(ffi)_ Fix old references to pact.v3.ffi +- V3 review +- Update git cliff configuration +- Update changelog for pact-python-ffi/0.4.28.0 +- Update changelog for pact-python-ffi/0.4.28.1 +- Fix CI badge links +- Update changelogs + +### ⚙️ Miscellaneous Tasks + +- Create cli and ffi packages +- _(ffi)_ Cleanup build script +- Ignore extensions +- Split out dependencies and tests +- Support pre and post release tags +- Remove reference count checks +- Store hatch venv in .venv +- Fix sub-project git cliff config +- _(ffi)_ Clean up data directory +- [**breaking**] Drop python 3.9 add 3.14 + > Python 3.9 is no longer supported. +- Update non-compliant docstrings and types +- Upgrade pymdownx extensions +- Fix json schema url +- Remove ruff sub-configs +- Switch to versioningit + +### Contributors + +- @JP-Ellis + + diff --git a/pact-python-cli/LICENSE b/pact-python-cli/LICENSE new file mode 100644 index 000000000..032bed571 --- /dev/null +++ b/pact-python-cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Pact Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pact-python-cli/README.md b/pact-python-cli/README.md new file mode 100644 index 000000000..4cf5e00e1 --- /dev/null +++ b/pact-python-cli/README.md @@ -0,0 +1,119 @@ + +# Pact Python CLI + +> [!NOTE] +> +> This package is used to package and bundle the Pact CLI _only_. It does not provide any Python functionality or API. + + +
+ + + + + + + + + + + + + + + + +
Package + Version + Python Versions + Downloads +
CI/CD + Test Status + Build Status + Build Status +
Meta + Hatch project + linting - Ruff + style - Ruff + types - Mypy + License +
Community + Issues + Discussions + GitHub Stars +
+ Slack + Stack Overflow + Twitter +
+ + +--- + +This sub-package is part of the [Pact Python](https://github.com/pact-foundation/pact-python) project and exists solely to distribute the [Pact CLI](https://github.com/pact-foundation/pact-standalone) as a Python package. If you are looking for the main Pact Python library for contract testing, please see the [root package](https://github.com/pact-foundation/pact-python#pact-python). + +It is used by version 2 of Pact Python, and can be used to install the Pact CLI in Python environments. + +The versionining of `pact-python-cli` is aligned with the Pact CLI versioning. For example, version `2.4.26.2` corresponds to Pact CLI version `2.4.26`, with the `.2` indicating that this is the third release of that Pact CLI version in the Python package (with the first release being `.0`). + +## Installation + +You can install this package via pip: + +```console +pip install pact-python-cli +``` + +## Contributing + +Contributions to this package are generally not required as it contains minimal Python functionality and generally only requires updating the version number. This is done by pushing a tag of the form `pact-python-cli/` which will automatically trigger a release build in the CI pipeline. + +To contribute to the Pact CLI itself, please refer to the [Pact Ruby Standalone repository](https://github.com/pact-foundation/pact-standalone). + +For contributing to Pact Python, see the [main contributing guide](https://github.com/pact-foundation/pact-python/blob/main/CONTRIBUTING.md). + +--- + +For questions or support, please visit the [Pact Foundation Slack](https://slack.pact.io) or [GitHub Discussions](https://github.com/pact-foundation/pact-python/discussions). + +--- diff --git a/pact-python-cli/cliff.toml b/pact-python-cli/cliff.toml new file mode 100644 index 000000000..0d5368605 --- /dev/null +++ b/pact-python-cli/cliff.toml @@ -0,0 +1,112 @@ +# git-cliff configuration file +# https://git-cliff.org/docs/configuration + +[changelog] +# template for the changelog header +header = """ +# Pact Python CLI Changelog + +All notable changes to this project will be documented in this file. + +Note that this _only_ includes changes to the Python re-packaging of the Pact CLI. For changes to the Pact CLI itself, see the [Pact CLI changelog](https://github.com/pact-foundation/pact-standalone/blob/master/CHANGELOG.md). + + + + + +""" + +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] _{{ timestamp | date(format="%Y-%m-%d") }}_ +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}_({{ commit.scope }})_ {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% if commit.breaking and commit.breaking_description %} + {{ " " }}\ + > {{ + commit.breaking_description + | split(pat="\n") + | join(sep=" ") + | replace(from=" ", to=" ") + | replace(from=" ", to=" ") + | replace(from=" ", to=" ") + | upper_first + }}\ + {% endif %}\ + {% endfor %} +{% endfor %} +{% if github.contributors %}\ + ### Contributors + {% for contributor in github.contributors %}\ + {% if contributor.username and contributor.username is ending_with("[bot]") %}{% continue %}{% endif %} + - @{{ contributor.username }}\ + {% endfor %} +{% endif %} + +""" + +# template for the changelog footer +footer = """\ + +""" + +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [] +# render body even when there are no releases to process +# render_always = true + +[git] +tag_pattern = "^pact-python-cli/.*$" +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Remove the PR number added by GitHub when merging PR in UI + { pattern = '\s*\(#([0-9]+)\)$', replace = "" }, + # Check spelling of the commit with https://github.com/crate-ci/typos + { pattern = ".*", replace_command = "typos --write-changes -" }, +] +# regex for parsing and grouping commits +commit_parsers = [ + # Ignore deps commits from the changelog + { message = "^(chore|fix)\\(deps.*\\)", skip = true }, + { message = "^chore: update changelog.*", skip = true }, + # Group commits by type + { group = "🚀 Features", message = "^feat" }, + { group = "🐛 Bug Fixes", message = "^fix" }, + { group = "🚜 Refactor", message = "^refactor" }, + { group = "⚡ Performance", message = "^perf" }, + { group = "🎨 Styling", message = "^style" }, + { group = "📚 Documentation", message = "^docs" }, + { group = "🧪 Testing", message = "^test" }, + { group = "◀️ Revert", message = "^revert" }, + { group = "⚙️ Miscellaneous Tasks", message = "^chore" }, + { group = "� Other", message = ".*" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = false +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +# Only include the current directory (relative to the .git directory) +include_paths = ["pact-python-cli/"] + +[remote.github] +owner = "pact-foundation" +repo = "pact-python" diff --git a/pact-python-cli/docs/SUMMARY.md b/pact-python-cli/docs/SUMMARY.md new file mode 100644 index 000000000..7ea99c1c4 --- /dev/null +++ b/pact-python-cli/docs/SUMMARY.md @@ -0,0 +1,5 @@ + + +- [`pact-cli`](README.md) +- [Changelog](CHANGELOG.md) +- [API](api/) diff --git a/pact-python-cli/hatch_build.py b/pact-python-cli/hatch_build.py new file mode 100644 index 000000000..a9ebf6a19 --- /dev/null +++ b/pact-python-cli/hatch_build.py @@ -0,0 +1,366 @@ +""" +Hatchling build hook. + +This hook is responsible for downloading and packaging the Pact CLI. +""" + +from __future__ import annotations + +import os +import shutil +import sys +import tarfile +import tempfile +import urllib.request +import zipfile +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping, MutableMapping, Sequence + +from hatchling.builders.config import BuilderConfig +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from packaging.tags import sys_tags + +PKG_DIR = Path(__file__).parent.resolve() / "src" / "pact_cli" + +# Remove when pact-standalone is removed +PACT_CLI_URL = "https://github.com/pact-foundation/pact-standalone/releases/download/v{version}/pact-{version}-{os}-{machine}.{ext}" + +# Remove fixed version and infer from package metadata when pact-cli versioning +# is adopted. +PACT_RUST_CLI_VERSION = "0.9.5" +PACT_RUST_CLI_URL = "https://github.com/pact-foundation/pact-cli/releases/download/v{version}/pact-{arch}-{os}{ext}" + + +class UnsupportedPlatformError(RuntimeError): + """ + Custom error raised when the current platform is not supported. + """ + + def __init__(self, platform: str) -> None: + """ + Initialize the exception. + + Args: + platform: + The unsupported platform. + """ + self.platform = platform + super().__init__(f"Unsupported platform {platform}") + + +class PactCliBuildHook(BuildHookInterface[BuilderConfig]): + """ + Custom hook to download Pact CLI binaries. + + This build hook is invoked by Hatch during the build process. Within + `pyproject.toml`, it takes the special name of `custom` (despite the name + below). + + For more references, see [Build hook + plugins](https://hatch.pypa.io/1.3/plugins/build-hook/reference/). + """ + + PLUGIN_NAME = "pact-cli" + + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 + """ + Initialize the build hook. + + For this hook, we additionally define the lib extension based on the + current platform. + """ + super().__init__(*args, **kwargs) + self.tmpdir = Path(tempfile.TemporaryDirectory().name) + self.tmpdir.mkdir(parents=True, exist_ok=True) + + def __del__(self) -> None: + """ + Clean up temporary files. + """ + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def clean(self, versions: Sequence[str]) -> None: # noqa: ARG002 + """ + Code called to clean. + + This is called by `hatch clean` or when the `-c`/`--clean` flag is + passed to `hatch build`. + """ + for subdir in ["bin", "lib", "data"]: + # TODO(epoch-transition): Remove "lib" when standalone dropped # noqa: TD003 + shutil.rmtree(PKG_DIR / subdir, ignore_errors=True) + + def initialize( + self, + version: str, # noqa: ARG002 + build_data: MutableMapping[str, object], + ) -> None: + """ + Code called immediately before each build. + + The CLI version is inferred from the package metadata. Specifically, the + first three segments of the version string are used. + + Args: + version: + Not used (but required by the parent class). + + build_data: + A mutable mapping to modify in-place used by Hatch when creating + the final wheel. + + Raises: + UnsupportedPlatformError: + If the CLI cannot be built (presumably due to an + incompatible platform). + """ + cli_version = ".".join(self.metadata.version.split(".")[:3]) + if not cli_version: + msg = "Failed to determine Pact CLI version." + self.app.display_error(msg) + raise ValueError(msg) + + try: + force_include = self._install_ruby_cli(cli_version) + except UnsupportedPlatformError as err: + # The Ruby standalone is deprecated and not available on all + # platforms (e.g. Windows ARM). Warn and continue rather than + # failing the build. + self.app.display_warning( + f"Pact Ruby standalone CLI not available for {err.platform}; skipping." + ) + force_include = {} + + try: + force_include = {**force_include, **self._install_rust_cli()} + except UnsupportedPlatformError as err: + msg = f"Pact CLI is not available for {err.platform}." + self.app.display_error(msg) + raise + + build_data["force_include"] = force_include + build_data["tag"] = self._infer_tag() + + def _sys_tag_platform(self) -> str: + """ + Get the platform tag from the current system tags. + + This is used to determine the target platform for the Pact binaries. + """ + return next(t.platform for t in sys_tags()) + + def _install_ruby_cli(self, version: str) -> Mapping[str, str]: + """ + Install the Pact Ruby standalone binaries. + + The binaries are installed in `src/pact_cli/bin`, and the relevant + version for the current operating system is determined automatically. + + Args: + version: + The Pact CLI version to install. + + Returns: + A mapping of `src` to `dst` to be used by Hatch when creating the + wheel. Each `src` is a full path in the current filesystem, and the + `dst` is the corresponding path within the wheel. + """ + url = self._pact_ruby_bin_url(version) + artefact = self._download(url) + self._extract(artefact) + return { + str(PKG_DIR / "bin"): "pact_cli/bin", + str(PKG_DIR / "lib"): "pact_cli/lib", + } + + def _pact_ruby_bin_url(self, version: str) -> str: + """ + Generate the download URL for the Pact Ruby binaries. + + Args: + version: + The Pact CLI version to download. + + Returns: + The URL to download the Pact Ruby binaries from. If the platform is + not supported, the resulting URL may be invalid. + """ + platform = self._sys_tag_platform() + + if platform.startswith("macosx"): + os_name = "osx" + ext = "tar.gz" + elif "linux" in platform: + os_name = "linux" + ext = "tar.gz" + elif platform.startswith("win"): + if platform.endswith(("arm64", "aarch64")): + # The Ruby standalone has no Windows ARM release. + raise UnsupportedPlatformError(platform) + os_name = "windows" + ext = "zip" + else: + raise UnsupportedPlatformError(platform) + + if platform.endswith(("arm64", "aarch64")): + machine = "arm64" + elif platform.endswith(("x86_64", "amd64")): + machine = "x86_64" + else: + raise UnsupportedPlatformError(platform) + + return PACT_CLI_URL.format( + version=version, + os=os_name, + machine=machine, + ext=ext, + ) + + def _pact_rust_bin_url(self, version: str) -> str: + """ + Generate the download URL for the Rust pact binary from pact-cli. + + The pact-cli release assets are plain binaries (not archives) named + ``pact-{arch}-{os}`` (e.g. ``pact-aarch64-macos``), with ``.exe`` + appended on Windows. + + Args: + version: + The pact-cli version to download. + + Returns: + The URL to the pact binary asset. + + Raises: + UnsupportedPlatformError: + If the current platform's OS or architecture is not recognised. + """ + platform = self._sys_tag_platform() + + if platform.startswith("macosx"): + os_name = "macos" + ext = "" + elif "linux" in platform: + # musl-based Linux targets (e.g. Alpine) are not distinguished; + # the linux-gnu binary is used for all Linux targets. + os_name = "linux-gnu" + ext = "" + elif platform.startswith("win"): + os_name = "windows-msvc" + ext = ".exe" + else: + raise UnsupportedPlatformError(platform) + + if platform.endswith(("arm64", "aarch64")): + arch = "aarch64" + elif platform.endswith(("x86_64", "amd64")): + arch = "x86_64" + else: + raise UnsupportedPlatformError(platform) + + return PACT_RUST_CLI_URL.format( + version=version, + arch=arch, + os=os_name, + ext=ext, + ) + + def _install_rust_cli(self) -> Mapping[str, str]: + """ + Install the Rust pact binary from pact-cli. + + Overwrites the `pact` binary bundled with pact-standalone. + + The binary is downloaded from the pact-cli GitHub release as a plain + executable (not an archive) and placed in `bin/` as `pact` + (or `pact.exe` on Windows). + """ + url = self._pact_rust_bin_url(PACT_RUST_CLI_VERSION) + artefact = self._download(url) + + bin_name = "pact.exe" if sys.platform == "win32" else "pact" + dest = PKG_DIR / "bin" / bin_name + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(artefact, dest) + if sys.platform != "win32": + dest.chmod(0o755) + + return {str(PKG_DIR / "bin"): "pact_cli/bin"} + + def _extract(self, artefact: Path) -> None: + """ + Extract the Pact binaries. + + The binaries in the `bin` directory require the underlying Ruby runtime + to be present, which is included in the `lib` directory. + + Args: + artefact: + The path to the downloaded artefact. + """ + if str(artefact).endswith(".zip"): + with zipfile.ZipFile(artefact) as f: + f.extractall(self.tmpdir) # noqa: S202 + + if str(artefact).endswith(".tar.gz"): + with tarfile.open(artefact) as f: + f.extractall(self.tmpdir) # noqa: S202 + + for d in ["bin", "lib"]: + if (PKG_DIR / d).is_dir(): + shutil.rmtree(PKG_DIR / d) + shutil.copytree( + Path(self.tmpdir) / "pact" / d, + PKG_DIR / d, + ) + + def _download(self, url: str) -> Path: + """ + Download the target URL. + + This will download the target URL to the `src/pact_cli/data` directory. + If the download artefact is already present, the existing artefact's + path will be returned without downloading it again. + + Args: + url: + The URL to download + + Returns: + The path to the downloaded artefact. + """ + filename = url.rsplit("/", maxsplit=1)[-1] + artefact = PKG_DIR / "data" / filename + artefact.parent.mkdir(parents=True, exist_ok=True) + + if not artefact.exists(): + urllib.request.urlretrieve(url, artefact) # noqa: S310 + return artefact + + def _infer_tag(self) -> str: + """ + Infer the tag for the current build. + + Since we have a pure Python wrapper around a binary CLI, we are not + tied to any specific Python version or ABI. As a result, we generate + `py3-none-{platform}` tags for the wheels. + """ + platform = self._sys_tag_platform() + + # On macOS, the version needs to be set based on the deployment target + # (i.e., the version of the system libraries). + if sys.platform == "darwin" and ( + deployment_target := os.environ.get("MACOSX_DEPLOYMENT_TARGET") + ): + target = deployment_target.replace(".", "_") + if platform.endswith("_arm64"): + platform = f"macosx_{target}_arm64" + elif platform.endswith("_x86_64"): + platform = f"macosx_{target}_x86_64" + else: + raise UnsupportedPlatformError(platform) + + return f"py3-none-{platform}" diff --git a/pact-python-cli/pyproject.toml b/pact-python-cli/pyproject.toml new file mode 100644 index 000000000..8cb440061 --- /dev/null +++ b/pact-python-cli/pyproject.toml @@ -0,0 +1,155 @@ +#:schema https://www.schemastore.org/pyproject.json + +[project] +name = "pact-python-cli" +version = "2.6.0.0" +description = "Pact CLI bundle for Python" +readme = "README.md" +license = "MIT" +keywords = ["cli", "contract-testing", "pact", "pact-python"] + +authors = [{ name = "Joshua Ellis", email = "josh@jpellis.me" }] +maintainers = [{ name = "Joshua Ellis", email = "josh@jpellis.me" }] + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Testing", +] + +requires-python = ">=3.10" + + [project.urls] + "Bug Tracker" = "https://github.com/pact-foundation/pact-python/issues" + "Changelog" = "https://github.com/pact-foundation/pact-python/blob/main/pact-python-cli/CHANGELOG.md" + "Documentation" = "https://docs.pact.io" + "Homepage" = "https://pact.io" + "Repository" = "https://github.com/pact-foundation/pact-python" + + [project.scripts] + pact = "pact_cli:_exec" + pact-broker = "pact_cli:_exec" + pact-message = "pact_cli:_exec" + pact_mock_server_cli = "pact_cli:_exec" + pact-mock-service = "pact_cli:_exec" + pact-plugin-cli = "pact_cli:_exec" + pact-provider-verifier = "pact_cli:_exec" + pact-stub-server = "pact_cli:_exec" + pact-stub-service = "pact_cli:_exec" + pact_verifier_cli = "pact_cli:_exec" + pactflow = "pact_cli:_exec" + +[dependency-groups] +# Linting and formatting tools use a more narrow specification to ensure +# developper consistency. All other dependencies are as above. +dev = ["ruff==0.15.13", { include-group = "test" }, { include-group = "types" }] +test = ["pytest~=9.0", "pytest-cov~=7.0"] +types = ["mypy==2.1.0"] + +## Build System +[build-system] +requires = ["hatchling", "packaging"] +build-backend = "hatchling.build" + +[tool] + [tool.cibuildwheel] + # The repair tool unfortunately did not like the bundled Ruby distributable, + # with false-positives missing libraries despite being bundled. + repair-wheel-command = "" + + [tool.cibuildwheel.windows] + archs = ["auto64"] + + [[tool.cibuildwheel.overrides]] + environment.MACOSX_DEPLOYMENT_TARGET = "10.13" + select = "*-macosx_x86_64" + + [[tool.cibuildwheel.overrides]] + environment.MACOSX_DEPLOYMENT_TARGET = "11.0" + select = "*-macosx_arm64" + + [tool.coverage] + [tool.coverage.paths] + pact-cli = ["/src/pact_cli"] + tests = ["/tests"] + + [tool.coverage.report] + exclude_lines = [ + "@(abc\\.)?abstractmethod", # Ignore abstract methods + "if TYPE_CHECKING:", # Ignore typing + "if __name__ == .__main__.:", # Ignore non-runnable code + "raise NotImplementedError", # Ignore defensive assertions + ] + + [tool.hatch] + [tool.hatch.build] + packages = ["src/pact_cli"] + + [tool.hatch.build.targets.wheel.hooks.custom] + patch = "hatch_build.py" + + [tool.hatch.envs] + [tool.hatch.envs.default] + installer = "uv" + path = ".venv" + extra-dependencies = [ + "hatchling", + "packaging", + "requests", + "setuptools ; python_version >= '3.12'", + ] + dependency-groups = ["dev"] + + [tool.hatch.envs.default.scripts] + all = ["format", "lint", "test", "typecheck"] + format = "ruff format {args}" + lint = "ruff check --show-fixes {args}" + test = "pytest tests/ {args}" + typecheck = ["typecheck-src", "typecheck-tests"] + typecheck-src = "mypy src/ {args}" + typecheck-tests = "mypy tests/ {args}" + + # Test environment for running unit tests. + [tool.hatch.envs.test] + installer = "uv" + path = ".venv/test" + dependency-groups = ["test"] + + [[tool.hatch.envs.test.matrix]] + python = ["3.10", "3.11", "3.12", "3.13", "3.14"] + + [tool.mypy] + # Overwrite the exclusions from the root pyproject.toml. + exclude = "" + + ## PyTest Configuration + [tool.pytest] + addopts = [ + # Coverage options + "--cov-config=pyproject.toml", + "--cov-report=xml", + "--cov=pact_cli", + "--import-mode=importlib", + ] + + log_date_format = "%H:%M:%S" + log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" + log_level = "NOTSET" + + ## Ruff Configuration + [tool.ruff] + extend = "../pyproject.toml" + + exclude = [] diff --git a/examples/message/tests/provider/__init__.py b/pact-python-cli/src/pact_cli/.gitkeep similarity index 100% rename from examples/message/tests/provider/__init__.py rename to pact-python-cli/src/pact_cli/.gitkeep diff --git a/pact-python-cli/src/pact_cli/__init__.py b/pact-python-cli/src/pact_cli/__init__.py new file mode 100644 index 000000000..403ba09f5 --- /dev/null +++ b/pact-python-cli/src/pact_cli/__init__.py @@ -0,0 +1,246 @@ +""" +Locate and provide paths to bundled or system Pact CLI executables. + +This module exists solely to bundle and distribute the Pact CLI tools as a +Python package. It does not provide any substantive functionality beyond +locating the Pact CLI executables and providing their paths for use in Python +code. + +The module exposes constants for the absolute paths to the Pact CLI executables (such as +`pact-broker`, `pact-message`, `pact-mock-service`, and `pact-provider-verifier`). + +By default, the module will use the binaries bundled with the package. If the +environment variable `PACT_USE_SYSTEM_BINS` is set to `TRUE` or `YES`, or if the bundled +binaries are unavailable, it will fall back to using the system-installed Pact CLI tools +if found in the system PATH. + +This package is intended for use as a dependency to ensure the Pact CLI is available in +Python environments, such as CI pipelines or local development, without requiring a +separate installation step. + +For more information, see the project README or +https://github.com/pact-foundation/pact-python. +""" + +from __future__ import annotations + +__author__ = "Pact Foundation" +__license__ = "MIT" +__url__ = "https://github.com/pact-foundation/pact-python" + +import os +import shutil +import sys +import warnings +from pathlib import Path +from typing import TYPE_CHECKING + +from pact_cli.__version__ import ( + __version__ as __version__, +) +from pact_cli.__version__ import ( + __version_tuple__ as __version_tuple__, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + +_USE_SYSTEM_BINS = os.getenv("PACT_USE_SYSTEM_BINS", "").upper() in ("TRUE", "YES") +_BIN_DIR = Path(__file__).parent.resolve() / "bin" +_DEPRECATED_COMMANDS: Mapping[str, str | None] = { + "pact-broker": "pact broker", + "pact-message": None, # being removed; no Rust equivalent + "pact-mock-service": "pact mock", + "pact-plugin-cli": "pact plugin", + "pact-provider-verifier": "pact verifier", + "pact-stub-server": "pact stub", + "pact-stub-service": "pact stub", + "pact_mock_server_cli": "pact mock", + "pact_verifier_cli": "pact verifier", + "pactflow": "pact pactflow", +} + + +def _telemetry_env() -> Mapping[str, str]: + """ + Get environment variables with Pact telemetry data. + + Returns a copy of the current environment with the following two keys added: + + - `PACT_EXECUTING_LANGUAGE`: Set to "python". + - `PACT_EXECUTING_LANGUAGE_VERSION`: Set to the current Python version + in "major.minor" format. + + Returns: + Environment dictionary Pact telemetry added. + """ + env = os.environ.copy() + env["PACT_EXECUTING_LANGUAGE"] = "python" + version = f"{sys.version_info.major}.{sys.version_info.minor}" + env["PACT_EXECUTING_LANGUAGE_VERSION"] = version + return env + + +def _exec() -> None: + """ + Execute Pact CLI tools routed through the generated entry points. + + This function is exposed via `pyproject.toml` console scripts and forwards + the provided command-line arguments to the matching Pact CLI binary. + + Raises: + SystemExit: + If the requested command is unknown or an executable cannot be + located. + """ + import sys # noqa: PLC0415 + + command = Path(sys.argv[0]).name + args = sys.argv[1:] if len(sys.argv) > 1 else [] + + if command not in ( + "pact", + "pact-broker", + "pact-message", + "pact-mock-service", + "pact-plugin-cli", + "pact-provider-verifier", + "pact-stub-server", + "pact-stub-service", + "pact_mock_server_cli", + "pact_verifier_cli", + "pactflow", + ): + print("Unknown command:", command, file=sys.stderr) # noqa: T201 + sys.exit(1) + + if command in _DEPRECATED_COMMANDS: + replacement = _DEPRECATED_COMMANDS[command] + if replacement: + print( # noqa: T201 + f"WARNING: '{command}' is deprecated and will be removed in a " + f"future release. Use '{replacement}' instead.\n", + file=sys.stderr, + flush=True, + ) + else: + print( # noqa: T201 + f"WARNING: '{command}' is deprecated and will be removed in a " + "future release.\n", + file=sys.stderr, + flush=True, + ) + + # To avoid finding the same executable, remove the current entry point's + # directory from PATH before searching. + script_dir = Path(sys.argv[0]).parent.resolve() + os.environ["PATH"] = os.pathsep.join( + p + for p in os.getenv("PATH", "").split(os.pathsep) + if Path(p).resolve() != script_dir + ) + executable = _find_executable(command) + + if not executable: + print(f"Command '{command}' not found.", file=sys.stderr) # noqa: T201 + sys.exit(1) + + os.execve(executable, [executable, *args], _telemetry_env()) # noqa: S606 + + +def _find_executable(executable: str) -> str | None: + """ + Find the path to an executable. + + This inspects the environment variable `PACT_USE_SYSTEM_BINS` to determine + whether to use the bundled Pact binaries or the system ones. Note that if + the local executables are not found, this will fall back to the system + executables (if found). + + Args: + executable: + The name of the executable to find without the extension. Python + will automatically append the correct extension for the current + platform. + + Returns: + The absolute path to the executable. + + Warns: + RuntimeWarning: + If the executable cannot be found in the system path. + """ + if _USE_SYSTEM_BINS: + bin_path = shutil.which(executable) + else: + bin_path = shutil.which(executable, path=str(_BIN_DIR)) + if bin_path is None: + system_path = shutil.which(executable) + if system_path is not None: + warnings.warn( + f"Bundled {executable} binary not found; " + "using system installation instead.", + RuntimeWarning, + stacklevel=2, + ) + bin_path = system_path + if bin_path is None: + msg = f"Unable to find {executable} binary executable." + warnings.warn(msg, RuntimeWarning, stacklevel=2) + return bin_path + + +PACT_PATH = _find_executable("pact") +""" +Path to the Pact executable +""" +BROKER_PATH = _find_executable("pact-broker") +""" +Path to the Pact Broker executable +""" +BROKER_CLIENT_PATH = _find_executable("pact-broker") +""" +Path to the Pact Broker executable + +This value is identical to `BROKER_PATH` and is provided for backward +compatibility. +""" +MESSAGE_PATH = _find_executable("pact-message") +""" +Path to the Pact Message executable +""" +MOCK_SERVICE_PATH = _find_executable("pact-mock-service") +""" +Path to the Pact Mock Service executable +""" +PLUGIN_CLI_PATH = _find_executable("pact-plugin-cli") +""" +Path to the Pact Plugin CLI executable +""" +VERIFIER_PATH = _find_executable("pact-provider-verifier") +""" +Path to the Pact Provider Verifier executable +""" +STUB_SERVER_PATH = _find_executable("pact-stub-server") +""" +Path to the Pact Stub Server executable +""" +STUB_SERVICE_PATH = _find_executable("pact-stub-service") +""" +Path to the Pact Stub Service executable +""" +MOCK_SERVER_PATH = _find_executable("pact_mock_server_cli") +""" +Path to the Pact Mock Server CLI executable +""" +VERIFIER_CLI_PATH = _find_executable("pact_verifier_cli") +""" +Path to the Pact Verifier CLI executable + +This is distinct to the `VERIFIER_PATH` which points to the older Ruby-based +CLI. +""" +PACTFLOW_PATH = _find_executable("pactflow") +""" +Path to the PactFlow CLI executable +""" diff --git a/pact-python-cli/src/pact_cli/__version__.py b/pact-python-cli/src/pact_cli/__version__.py new file mode 100644 index 000000000..9ecfb7454 --- /dev/null +++ b/pact-python-cli/src/pact_cli/__version__.py @@ -0,0 +1,10 @@ +"""Version information for pact-python-cli.""" + +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("pact-python-cli") +except PackageNotFoundError: + __version__ = "unknown" + +__version_tuple__ = tuple(int(x) for x in __version__.split(".") if x.isdigit()) diff --git a/tests/cli/__init__.py b/pact-python-cli/src/pact_cli/py.typed similarity index 100% rename from tests/cli/__init__.py rename to pact-python-cli/src/pact_cli/py.typed diff --git a/pact-python-cli/tests/test_init.py b/pact-python-cli/tests/test_init.py new file mode 100644 index 000000000..904e2e62a --- /dev/null +++ b/pact-python-cli/tests/test_init.py @@ -0,0 +1,239 @@ +"""Test the values in exported constants.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + +import pact_cli + + +def bin_to_sitepackages(exec_path: str | Path) -> Path: + """ + Compute the expected site-packages directory for a Pact executable. + + Args: + exec_path: + Path to the binary whose site-packages root should be derived. + + Returns: + Path to the site-packages directory associated with the executable. + """ + if os.name == "nt": + return Path(exec_path).parents[1] / "Lib" / "site-packages" + return ( + Path(exec_path).parents[1] + / "lib" + / f"python{sys.version_info.major}.{sys.version_info.minor}" + / "site-packages" + ) + + +def assert_in_sys_path(p: str | Path) -> None: + """ + Assert that a resolved path exists in ``sys.path``. + + This performs normalization on case-insensitive filesystems to avoid + comparison errors. + + Args: + p: + Path that should be discoverable via `sys.path`. + """ + if os.name == "nt": + assert str(p).lower() in (path.lower() for path in sys.path) + else: + assert str(p) in sys.path + + +@pytest.mark.parametrize( + ("constant", "expected"), + [ + pytest.param("BROKER_CLIENT_PATH", "pact-broker", id="pact-broker"), + pytest.param("BROKER_PATH", "pact-broker", id="pact-broker"), + pytest.param("MESSAGE_PATH", "pact-message", id="pactmessage"), + pytest.param( + "MOCK_SERVER_PATH", "pact_mock_server_cli", id="pact_mock_server_cli" + ), + pytest.param("MOCK_SERVICE_PATH", "pact-mock-service", id="pact-mock-service"), + pytest.param("PACTFLOW_PATH", "pactflow", id="pactflow"), + pytest.param("PACT_PATH", "pact", id="pact"), + pytest.param("PLUGIN_CLI_PATH", "pact-plugin-cli", id="pact-plugin-cli"), + pytest.param("STUB_SERVER_PATH", "pact-stub-server", id="pact-stub-server"), + pytest.param("STUB_SERVICE_PATH", "pact-stub-service", id="pact-stub-service"), + pytest.param("VERIFIER_CLI_PATH", "pact_verifier_cli", id="pact_verifier_cli"), + pytest.param( + "VERIFIER_PATH", "pact-provider-verifier", id="pact-provider-verifier" + ), + ], +) +def test_constants_are_valid_executable_paths(constant: str, expected: str) -> None: + value: str = getattr(pact_cli, constant) + if os.name == "nt": + # As the Windows filesystem is case insensitive, we must normalize it. + assert value.lower().endswith((f"{expected}.bat", f"{expected}.exe")) + else: + assert value.endswith(expected) + + +@pytest.mark.parametrize( + "executable", + [ + pytest.param("pact", id="pact"), + pytest.param("pact-broker", id="pact-broker"), + pytest.param("pact-message", id="pact-message"), + pytest.param("pact-plugin-cli", id="pact-plugin-cli"), + pytest.param("pact-provider-verifier", id="pact-provider-verifier"), + pytest.param("pact-stub-server", id="pact-stub-server"), + pytest.param("pact-stub-service", id="pact-stub-service"), + pytest.param("pact_mock_server_cli", id="pact_mock_server_cli"), + pytest.param("pact_verifier_cli", id="pact_verifier_cli"), + pytest.param("pactflow", id="pactflow"), + ], +) +def test_cli_exec_wrapper(executable: str) -> None: + exec_path = shutil.which(executable) + assert exec_path + + site_packages = bin_to_sitepackages(exec_path) + assert site_packages.is_dir() + assert_in_sys_path(site_packages) + + result = subprocess.run( # noqa: S603 + [exec_path, "--help"], + check=False, # Some CLIs return non-zero for --help + text=True, + capture_output=True, + ) + assert "pact" in (result.stdout + result.stderr).lower() + + result = subprocess.run( # noqa: S603 + [exec_path], + check=False, # Some CLIs return non-zero for --help + text=True, + capture_output=True, + ) + assert "pact" in (result.stdout + result.stderr).lower() + + +def test_cli_exec_wrapper_for_mock_service() -> None: + """ + Same as `test_cli_exec_wrapper` for the `pact-mock-server`. + + The Pact mock service is a long running service, as it is expected to run a + mock service which can be tested against. The test pattern above doesn't + work, and instead, we spawn the process, wait a bit, terminate it, and then + check the output. + """ + executable = "pact-mock-service" + exec_path = shutil.which(executable) + assert exec_path + site_packages = bin_to_sitepackages(exec_path) + assert site_packages.is_dir() + assert_in_sys_path(site_packages) + + process = subprocess.Popen( # noqa: S603 + [exec_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + time.sleep(2) + process.terminate() + process.wait() + + stdout, stderr = process.communicate() + assert "pact" in (stdout + stderr).lower() + + +@pytest.mark.parametrize( + "executable", + [ + pytest.param("pact", id="pact"), + pytest.param("pact-broker", id="pact-broker"), + pytest.param("pact-message", id="pact-message"), + pytest.param("pact-mock-service", id="pact-mock-service"), + pytest.param("pact-plugin-cli", id="pact-plugin-cli"), + pytest.param("pact-provider-verifier", id="pact-provider-verifier"), + pytest.param("pact-stub-server", id="pact-stub-server"), + pytest.param("pact-stub-service", id="pact-stub-service"), + pytest.param("pact_mock_server_cli", id="pact_mock_server_cli"), + pytest.param("pact_verifier_cli", id="pact_verifier_cli"), + pytest.param("pactflow", id="pactflow"), + ], +) +def test_exec_directly(executable: str) -> None: + """ + Invoke ``pact_cli._exec`` directly to confirm ``execv`` receives the command. + """ + cmd: str + args: list[str] + + with ( + patch.object(sys, "argv", new=[executable, "--help"]), + patch("os.execve") as mock_execve, + ): + pact_cli._exec() # noqa: SLF001 + mock_execve.assert_called_once() + cmd, args, env = mock_execve.call_args[0] + assert (os.sep + executable) in cmd + assert args == [cmd, "--help"] + assert env + + patch.object(sys, "argv", new=[executable]) + with ( + patch.object(sys, "argv", new=[executable]), + patch("os.execve") as mock_execve, + ): + pact_cli._exec() # noqa: SLF001 + mock_execve.assert_called_once() + cmd, args, env = mock_execve.call_args[0] + assert (os.sep + executable) in cmd + assert args == [cmd] + assert env + + +def test_deprecated_command_warns_with_replacement( + capsys: pytest.CaptureFixture[str], +) -> None: + """Ruby commands with a Rust equivalent print a hint to stderr.""" + with ( + patch.object(sys, "argv", new=["pact-broker", "--help"]), + patch("os.execve"), + ): + pact_cli._exec() # noqa: SLF001 + captured = capsys.readouterr() + assert "deprecated" in captured.err + assert "pact broker" in captured.err + + +def test_deprecated_command_warns_without_replacement( + capsys: pytest.CaptureFixture[str], +) -> None: + """Ruby commands without a Rust equivalent print a generic warning.""" + with ( + patch.object(sys, "argv", new=["pact-message", "--help"]), + patch("os.execve"), + ): + pact_cli._exec() # noqa: SLF001 + captured = capsys.readouterr() + assert "deprecated" in captured.err + assert "Use " not in captured.err # no replacement hint + + +def test_pact_command_does_not_warn(capsys: pytest.CaptureFixture[str]) -> None: + """The Rust pact binary does not trigger any deprecation warning.""" + with ( + patch.object(sys, "argv", new=["pact", "--help"]), + patch("os.execve"), + ): + pact_cli._exec() # noqa: SLF001 + captured = capsys.readouterr() + assert "deprecated" not in captured.err diff --git a/pact-python-cli/tests/test_telemetry.py b/pact-python-cli/tests/test_telemetry.py new file mode 100644 index 000000000..0437dd22a --- /dev/null +++ b/pact-python-cli/tests/test_telemetry.py @@ -0,0 +1,35 @@ +"""Tests for telemetry environment variables.""" + +from __future__ import annotations + +import sys +from unittest.mock import patch + +from pact_cli import _telemetry_env + + +def test_telemetry_env_sets_language() -> None: + env = _telemetry_env() + assert env["PACT_EXECUTING_LANGUAGE"] == "python" + + +def test_telemetry_env_sets_version() -> None: + env = _telemetry_env() + expected_version = f"{sys.version_info.major}.{sys.version_info.minor}" + assert env["PACT_EXECUTING_LANGUAGE_VERSION"] == expected_version + + +def test_telemetry_env_preserves_existing_env() -> None: + mock_env = {"EXISTING_VAR": "existing_value", "PATH": "/usr/bin"} + with patch("os.environ", mock_env): + env = _telemetry_env() + assert env["EXISTING_VAR"] == "existing_value" + assert env["PATH"] == "/usr/bin" + assert "PACT_EXECUTING_LANGUAGE" in env + assert "PACT_EXECUTING_LANGUAGE_VERSION" in env + + +def test_telemetry_env_returns_copy() -> None: + env1 = _telemetry_env() + env2 = _telemetry_env() + assert env1 is not env2 diff --git a/pact-python-ffi/.gitignore b/pact-python-ffi/.gitignore new file mode 100644 index 000000000..48af8469c --- /dev/null +++ b/pact-python-ffi/.gitignore @@ -0,0 +1,5 @@ +src/pact_ffi/data +src/pact_ffi/*.pyd +src/pact_ffi/*.so +src/pact_ffi/*.dylib +src/pact_ffi/*.a diff --git a/pact-python-ffi/CHANGELOG.md b/pact-python-ffi/CHANGELOG.md new file mode 100644 index 000000000..e49d92e53 --- /dev/null +++ b/pact-python-ffi/CHANGELOG.md @@ -0,0 +1,141 @@ +# Pact Python FFI Changelog + +All notable changes to this project will be documented in this file. + +Note that this _only_ includes changes to the Python FFI interface. For changes to the Pact FFI itself, see the [Pact FFI changelog](https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_ffi/CHANGELOG.md). + + + + + +## [pact-python-ffi/0.5.4.0] _2026-05-04_ + +- Added `add_interaction_reference` + +## [pact-python-ffi/0.5.3.0] _2026-04-16_ + +### 🚀 Features + +- Removed: + - `create_mock_server` (use `create_mock_server_for_transport` instead) + - `create_mock_server_for_pact` (use `create_mock_server_for_pact_and_transport` instead) + - `verify` + - `verifier_cli_args` +- Added: + - `verifier_set_follow_redirects` + - `using_plugin_with_delay` +- Allow iteration over all interactions +- Implement the Pact class +- Add handle to pointer conversion +- Add casting interaction to subtypes +- Add iterator over all interactions + +### 🐛 Bug Fixes + +- Incorrect sync http deletion + +### 📚 Documentation + +- Update changelogs + +### ⚙️ Miscellaneous Tasks + +- Update non-compliant docstrings and types +- Upgrade pymdownx extensions +- Fix json schema url +- Remove ruff sub-configs +- Switch to versioningit +- Ensure pact interactions get deleted +- Add ruff ignores for tests +- Refactor ffi tests +- Remove versioningit, switch to static version in pyproject.toml +- Minor update to cliff config +- Replace taplo with tombi + +### Contributors + +- @JP-Ellis +- @Nikhil172913832 + +## [pact-python-ffi/0.4.28.2] _2025-10-06_ + +### 📚 Documentation + +- Update changelog for pact-python-ffi/0.4.28.1 +- Fix CI badge links + +### ⚙️ Miscellaneous Tasks + +- [**breaking**] Drop python 3.9 add 3.14 + > Python 3.9 is no longer supported. + +### Contributors + +- @JP-Ellis + +## [pact-python-ffi/0.4.28.1] _2025-08-28_ + +### 🐛 Bug Fixes + +- _(ffi)_ Make version dynamic + +### 📚 Documentation + +- Update changelog for pact-python-ffi/0.4.28.0 + +### ⚙️ Miscellaneous Tasks + +- Fix sub-project git cliff config +- _(ffi)_ Clean up data directory + +### Contributors + +- @JP-Ellis + +## [pact-python-ffi/0.4.28.0] _2025-08-26_ + +### 🚀 Features + +- _(v3)_ [**breaking**] Remove pact.v3.ffi module + > `pact.v3.ffi` is removed, and to be replaced by `pact_ffi`. That is, `pact.v3.ffi.$fn` should be replaced by `pact_ffi.$fn`. +- _(ffi)_ Upgrade lib to 0.4.28 + +### 🐛 Bug Fixes + +- Allow none in with_metadata + +### 📚 Documentation + +- Update changelog for pact-python-ffi/0.4.22.0 +- _(ffi)_ Fix old references to pact.v3.ffi +- V3 review +- Update git cliff configuration + +### ⚙️ Miscellaneous Tasks + +- _(ffi)_ Cleanup build script +- Ignore extensions +- Split out dependencies and tests +- Support pre and post release tags +- Remove reference count checks +- Store hatch venv in .venv + +### Contributors + +- @JP-Ellis + +## [pact-python-ffi/0.4.22.0] _2025-07-29_ + +### 🚀 Features + +- _(ffi)_ Add standalone ffi package + +### ⚙️ Miscellaneous Tasks + +- Create cli and ffi packages + +### Contributors + +- @JP-Ellis + + diff --git a/pact-python-ffi/LICENSE b/pact-python-ffi/LICENSE new file mode 100644 index 000000000..032bed571 --- /dev/null +++ b/pact-python-ffi/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Pact Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pact-python-ffi/README.md b/pact-python-ffi/README.md new file mode 100644 index 000000000..788f0814f --- /dev/null +++ b/pact-python-ffi/README.md @@ -0,0 +1,126 @@ +# Pact Python FFI + +> [!NOTE] +> +> This package provides direct access to the Pact Foreign Function Interface (FFI) with minimal abstraction. It is intended for advanced users who need low-level control over Pact operations in Python. + + +
+ + + + + + + + + + + + + + + + +
Package + Version + Python Versions + Downloads +
CI/CD + Test Status + Build Status + Build Status +
Meta + Hatch project + linting - Ruff + style - Ruff + types - Mypy + License +
Community + Issues + Discussions + GitHub Stars +
+ Slack + Stack Overflow + Twitter +
+ + +--- + +This sub-package is part of the [Pact Python](https://github.com/pact-foundation/pact-python) project and exists to expose the [Pact FFI](https://github.com/pact-foundation/pact-reference) directly to Python. If you are looking for the main Pact Python library for contract testing, please see the [root package](https://github.com/pact-foundation/pact-python#pact-python). + +## Overview + +- The module provides a thin Python wrapper around the Pact FFI (C API). +- Most classes correspond directly to structs from the FFI, and are designed to wrap the underlying C pointers. +- Many classes implement the `__del__` method to ensure memory allocated by the Rust library is freed when the Python object is destroyed, preventing memory leaks. +- Functions from the FFI are exposed directly: if a function `foo` exists in the FFI, it is accessible as `pact_ffi.foo`. +- The API is not guaranteed to be stable and is intended for use by advanced users or for building higher-level libraries. For typical contract testing, use the main Pact Python client library. + +## Installation + +You can install this package via pip: + +```console +pip install pact-python-ffi +``` + +## Usage + +This package exposes the raw FFI bindings for Pact. It is suitable for advanced use cases, custom integrations, or for building higher-level libraries. For typical contract testing, prefer using the main Pact Python library. + +## Contributing + +As this is a relatively thin wrapper around the Pact FFI, the code is unlikely to change frequently; however, contributions to improve the coverage of the FFI bindings or to improve existing functionality are welcome. See the [main contributing guide](https://github.com/pact-foundation/pact-python/blob/main/CONTRIBUTING.md) for details. + +To release a new version of `pact-python-ffi`, simply push a tag in the format `pact-python-ffi/x.y.z.w`. This will automatically trigger a release process, pulling in version `x.y.z` of the underlying Pact FFI. Before creating and pushing such a tag, please ensure that the Python wrapper has been updated to reflect any changes or updates in the corresponding FFI version. + +Higher-level abstractions or utilities should be implemented in separate libraries (such as [`pact-python`](https://github.com/pact-foundation/pact-python)). + +--- + +For questions or support, please visit the [Pact Foundation Slack](https://slack.pact.io) or [GitHub Discussions](https://github.com/pact-foundation/pact-python/discussions) + +--- diff --git a/pact-python-ffi/cliff.toml b/pact-python-ffi/cliff.toml new file mode 100644 index 000000000..dacd83159 --- /dev/null +++ b/pact-python-ffi/cliff.toml @@ -0,0 +1,112 @@ +# git-cliff configuration file +# https://git-cliff.org/docs/configuration + +[changelog] +# template for the changelog header +header = """ +# Pact Python FFI Changelog + +All notable changes to this project will be documented in this file. + +Note that this _only_ includes changes to the Python FFI interface. For changes to the Pact FFI itself, see the [Pact FFI changelog](https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_ffi/CHANGELOG.md). + + + + + +""" + +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] _{{ timestamp | date(format="%Y-%m-%d") }}_ +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}_({{ commit.scope }})_ {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% if commit.breaking and commit.breaking_description %} + {{ " " }}\ + > {{ + commit.breaking_description + | split(pat="\n") + | join(sep=" ") + | replace(from=" ", to=" ") + | replace(from=" ", to=" ") + | replace(from=" ", to=" ") + | upper_first + }}\ + {% endif %}\ + {% endfor %} +{% endfor %} +{% if github.contributors %}\ + ### Contributors + {% for contributor in github.contributors %}\ + {% if contributor.username and contributor.username is ending_with("[bot]") %}{% continue %}{% endif %} + - @{{ contributor.username }}\ + {% endfor %} +{% endif %} + +""" + +# template for the changelog footer +footer = """\ + +""" + +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [] +# render body even when there are no releases to process +# render_always = true + +[git] +tag_pattern = "^pact-python-ffi/.*$" +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Remove the PR number added by GitHub when merging PR in UI + { pattern = '\s*\(#([0-9]+)\)$', replace = "" }, + # Check spelling of the commit with https://github.com/crate-ci/typos + { pattern = ".*", replace_command = "typos --write-changes -" }, +] +# regex for parsing and grouping commits +commit_parsers = [ + # Ignore deps commits from the changelog + { message = "^(chore|fix)\\(deps.*\\)", skip = true }, + { message = "^chore: update changelog.*", skip = true }, + # Group commits by type + { group = "🚀 Features", message = "^feat" }, + { group = "🐛 Bug Fixes", message = "^fix" }, + { group = "🚜 Refactor", message = "^refactor" }, + { group = "⚡ Performance", message = "^perf" }, + { group = "🎨 Styling", message = "^style" }, + { group = "📚 Documentation", message = "^docs" }, + { group = "🧪 Testing", message = "^test" }, + { group = "◀️ Revert", message = "^revert" }, + { group = "⚙️ Miscellaneous Tasks", message = "^chore" }, + { group = "� Other", message = ".*" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = false +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +# Only include the current directory (relative to the .git directory) +include_paths = ["pact-python-ffi/"] + +[remote.github] +owner = "pact-foundation" +repo = "pact-python" diff --git a/pact-python-ffi/docs/SUMMARY.md b/pact-python-ffi/docs/SUMMARY.md new file mode 100644 index 000000000..cc957d0cb --- /dev/null +++ b/pact-python-ffi/docs/SUMMARY.md @@ -0,0 +1,5 @@ + + +- [`pact-ffi`](README.md) +- [Changelog](CHANGELOG.md) +- [API](api/) diff --git a/pact-python-ffi/hatch_build.py b/pact-python-ffi/hatch_build.py new file mode 100644 index 000000000..80d0e7bc6 --- /dev/null +++ b/pact-python-ffi/hatch_build.py @@ -0,0 +1,429 @@ +""" +Hatchling build hook. + +This hook is responsible for downloading the Pact FFI library and building the +CFFI bindings for it. +""" + +from __future__ import annotations + +import gzip +import os +import shutil +import subprocess +import sys +import tempfile +import urllib.request +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import cffi +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from packaging.tags import sys_tags + +if TYPE_CHECKING: + from collections.abc import Mapping, MutableMapping, Sequence + +PKG_DIR = Path(__file__).parent.resolve() / "src" / "pact_ffi" +PACT_LIB_URL = "https://github.com/pact-foundation/pact-reference/releases/download/libpact_ffi-v{version}/{prefix}pact_ffi-{os}-{platform}{suffix}.{ext}" + + +class UnsupportedPlatformError(RuntimeError): + """ + Custom error raised when the current platform is not supported. + """ + + def __init__(self, platform: str) -> None: + """ + Initialize the exception. + + Args: + platform: + The unsupported platform. + """ + self.platform = platform + super().__init__(f"Unsupported platform {platform}") + + +class PactBuildHook(BuildHookInterface[Any]): + """ + Custom hook to download Pact binaries. + + This build hook is invoked by Hatch during the build process. Within + `pyproject.toml`, is takes the special name of `custom` (despite the name + below). + + For more references, see [Build hook + plugins](https://hatch.pypa.io/1.3/plugins/build-hook/reference/). + """ + + PLUGIN_NAME = "pact-ffi" + + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 + """ + Initialize the build hook. + + For this hook, we additionally define the lib extension based on the + current platform. + """ + super().__init__(*args, **kwargs) + self.tmpdir = Path(tempfile.TemporaryDirectory().name) + self.tmpdir.mkdir(parents=True, exist_ok=True) + + def __del__(self) -> None: + """ + Clean up temporary files. + """ + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def clean(self, versions: Sequence[str]) -> None: # noqa: ARG002 + """ + Code called to clean. + + This is called by `hatch clean` or when the `-c`/`--clean` flag is + passed to `hatch build`. + """ + # Cleanup the Python extension + for ffi in PKG_DIR.glob("ffi.*"): + if ffi.suffix in (".so", ".dylib", ".dll", ".a", ".pyd"): + ffi.unlink() + # Cleanup the Pact FFI library + for lib in PKG_DIR.glob("*pact_ffi.*"): + lib.unlink() + # Cleanup the data directory + shutil.rmtree(PKG_DIR / "data", ignore_errors=True) + + def initialize( + self, + version: str, # noqa: ARG002 + build_data: MutableMapping[str, object], + ) -> None: + """ + Code called immediately before each build. + + Args: + version: + Not used (but required by the parent class). + + build_data: + A mutable mapping to modify in-place used by Hatch when creating + the final wheel. + + Raises: + UnsupportedPlatformError: + If the C extension cannot be built (presumably due to an + incompatible platform). + """ + ffi_version = ".".join(self.metadata.version.split(".")[:3]) + if not ffi_version: + msg = "Failed to determine Pact FFI version." + self.app.display_error(msg) + raise ValueError(msg) + + try: + build_data["force_include"] = self._install(ffi_version) + except UnsupportedPlatformError as err: + msg = f"Pact FFI library is not available for {err.platform}" + self.app.display_error(msg) + raise + + self.app.display_debug(f"Wheel artefacts: {build_data['force_include']}") + build_data["tag"] = self._infer_tag() + + def _sys_tag_platform(self) -> str: + """ + Get the platform tag from the current system tags. + + This is used to determine the target platform for the Pact library. + """ + return next(t.platform for t in sys_tags()) + + def _install(self, version: str) -> Mapping[str, str]: + """ + Install the Pact library binary. + + This will download the Pact library binary for the current platform and + build the CFFI bindings for it. + + Args: + version: + The Pact version to install. + + Returns: + A mapping of `src` to `dst` to be used by Hatch when creating the + wheel. Each `src` is a full path in the current filesystem, and the + `dst` is the corresponding path within the wheel. + """ + # Download the Pact library binary and header file + lib_url = self._lib_url(version) + header = self._download(lib_url.rsplit("/", 1)[0] + "/pact.h") + lib = self._extract_lib(self._download(lib_url)) + if lib.suffix == ".dll": + dll_lib = self._extract_lib( + self._download(lib_url.replace(".dll.gz", ".dll.lib.gz")) + ) + else: + dll_lib = None + + # Compile the FFI extension + extension = self._compile(lib, header) + + # Copy into the package directory, using the ABI3 marking for broad + # compatibility. + # NOTE: Windows does _not_ use the version infixation + name = extension.name.split(".")[0] + suffix = extension.suffix + infix = ".abi3" if os.name != "nt" else "" + extension_dest = f"{name}{infix}{suffix}" + shutil.copy(extension, PKG_DIR / extension_dest) + + if pact_lib_dir := os.getenv("PACT_LIB_DIR"): + # Copy the library to make it available by other processes (such as + # the wheel repair). + dir_path = Path(pact_lib_dir) + dir_path.mkdir(parents=True, exist_ok=True) + self.app.display_debug(f"Copying {lib.name} into {dir_path}") + shutil.copy(lib, dir_path / lib.name) + if dll_lib: + self.app.display_debug(f"Copying {dll_lib.name} into {dir_path}") + shutil.copy(dll_lib, dir_path / dll_lib.name) + + return {str(extension): f"pact_ffi/{extension_dest}"} + + def _lib_url(self, version: str) -> str: # noqa: C901, PLR0912 + """ + Generate the download URL for the Pact library. + + Args: + version: + The upstream Pact version. + + Returns: + The URL to download the Pact library from. + + Raises: + UnsupportedPlatformError: + If the current platform is not supported. + """ + wheel_platform = self._sys_tag_platform() + + aarch64 = ("_arm64", "_aarch64") + x86_64 = ("_x86_64", "_amd64") + + # Simplified platform and architecture detection + if wheel_platform.startswith("macosx"): + os, ext = "macos", "dylib.gz" + prefix = "lib" + suffix = "" + if wheel_platform.endswith(aarch64): + platform = "aarch64" + elif wheel_platform.endswith(x86_64): + platform = "x86_64" + else: + raise UnsupportedPlatformError(wheel_platform) + + elif wheel_platform.startswith("musllinux"): + os, ext = "linux", "a.gz" # MUSL uses static library + prefix = "lib" + suffix = "-musl" + if wheel_platform.endswith(aarch64): + platform = "aarch64" + elif wheel_platform.endswith(x86_64): + platform = "x86_64" + else: + raise UnsupportedPlatformError(wheel_platform) + + elif wheel_platform.startswith("manylinux"): + os, ext = "linux", "so.gz" + prefix = "lib" + suffix = "" + if wheel_platform.endswith(aarch64): + platform = "aarch64" + elif wheel_platform.endswith(x86_64): + platform = "x86_64" + else: + raise UnsupportedPlatformError(wheel_platform) + + elif wheel_platform.startswith("win"): + # TODO: Switch to using `dll.gz` + # https://github.com/python-cffi/cffi/issues/182 + os, ext = "windows", "dll.gz" + prefix = "" + suffix = "" + if wheel_platform.endswith(aarch64): + platform = "aarch64" + elif wheel_platform.endswith(x86_64): + platform = "x86_64" + else: + raise UnsupportedPlatformError(wheel_platform) + + else: + raise UnsupportedPlatformError(wheel_platform) + + return PACT_LIB_URL.format( + version=version, + prefix=prefix, + os=os, + platform=platform, + suffix=suffix, + ext=ext, + ) + + def _extract_lib(self, artefact: Path) -> Path: + """ + Extract the Pact library. + + Args: + artefact: + The URL to download the Pact binaries from. + """ + target = PKG_DIR / (artefact.name.split("-")[0] + artefact.suffixes[-2]) + with ( + gzip.open(artefact, "rb") as f_in, + target.open("wb") as f_out, + ): + shutil.copyfileobj(f_in, f_out) + self.app.display_debug(f"Extracted Pact library to {target}") + return target + + def _compile(self, lib: Path, header: Path) -> Path: + """ + Build the CFFI bindings for the Pact library. + + Args: + lib: + The path to the Pact library binary. + + header: + The path to the Pact library header file. + """ + if os.name == "nt": + extra_libs = [ + "advapi32", + "bcrypt", + "crypt32", + "iphlpapi", + "ncrypt", + "netapi32", + "ntdll", + "ole32", + "oleaut32", + "pdh", + "powrprof", + "psapi", + "secur32", # spellchecker:disable-line + "shell32", + "user32", + "userenv", + "ws2_32", + ] + else: + extra_libs = [] + + ffibuilder = cffi.FFI() + ffibuilder.cdef( + "\n".join( + line + for line in header.read_text().splitlines() + if not line.strip().startswith("#") + ) + ) + + linker_args: list[str] = [] + if sys.platform == "darwin": + # On macOS, we pad the headers so that install_name_tool can + # subsequently adjust them. + linker_args.append("-Wl,-headerpad_max_install_names") + elif sys.platform == "linux": + # On Linux, we set the RPATH + linker_args.append(f"-Wl,-rpath,{lib.parent}") + elif sys.platform == "win32": + # Windows has no equivalent to RPATH, instead, the end-user must + # ensure that the PATH environment variable is updated to include + # the directory containing the Pact library. + self.app.display_warning( + "On Windows, ensure that the PATH environment variable includes " + f"{lib.parent} to load the Pact library at runtime." + ) + + ffibuilder.set_source( + "ffi", + header.read_text(), + libraries=["pact_ffi", *extra_libs], + library_dirs=[str(lib.parent)], + extra_link_args=linker_args, + ) + extension = Path(ffibuilder.compile(verbose=True, tmpdir=str(self.tmpdir))) + + if sys.platform == "darwin": + self.app.display_debug(f"Updating install names for {extension}") + subprocess.check_call([ # noqa: S603, S607 + "install_name_tool", + "-change", + "libpact_ffi.dylib", + str(lib), + str(extension), + ]) + + self.app.display_debug(f"Compiled CFFI bindings to {extension}") + return extension + + def _download(self, url: str) -> Path: + """ + Download the target URL. + + This will download the target URL to the `src/pact_ffi/data` directory. + If the download artefact is already present, the existing artefact's + path will be returned without downloading it again. + + Args: + url: + The URL to download + + Returns: + The path to the downloaded artefact. + """ + filename = url.rsplit("/", maxsplit=1)[-1] + artefact = PKG_DIR / "data" / filename + artefact.parent.mkdir(parents=True, exist_ok=True) + + if not artefact.exists(): + self.app.display_debug(f"Downloading {url} to {artefact}") + urllib.request.urlretrieve(url, artefact) # noqa: S310 + else: + self.app.display_debug(f"Using cached artefact {artefact}") + + return artefact + + def _infer_tag(self) -> str: + """ + Infer the tag for the current build. + + The bindings are built to target ABI3, which is compatible with multiple + Python versions. As a result, we generate `py{version}-abi3-{platform}` + tags for the wheels. + + While the ABI3 interface was introduced in Python 3.2, we target the + earliest supported version of Python in the Python wrapper. + + Returns: + The tag for the current build. + """ + python_version = f"{sys.version_info.major}{sys.version_info.minor}" + + platform = self._sys_tag_platform() + + # On macOS, the version needs to be set based on the deployment target + # (i.e., the version of the system libraries). + if sys.platform == "darwin" and ( + deployment_target := os.getenv("MACOSX_DEPLOYMENT_TARGET") + ): + target = deployment_target.replace(".", "_") + if platform.endswith("_arm64"): + platform = f"macosx_{target}_arm64" + elif platform.endswith("_x86_64"): + platform = f"macosx_{target}_x86_64" + else: + raise UnsupportedPlatformError(platform) + + return f"cp{python_version}-abi3-{platform}" diff --git a/pact-python-ffi/pyproject.toml b/pact-python-ffi/pyproject.toml new file mode 100644 index 000000000..9214c367e --- /dev/null +++ b/pact-python-ffi/pyproject.toml @@ -0,0 +1,166 @@ +#:schema https://www.schemastore.org/pyproject.json + +[project] +name = "pact-python-ffi" +version = "0.5.4.0" +description = "Python bindings for the Pact FFI library" +readme = "README.md" +license = "MIT" +keywords = ["contract-testing", "ffi", "pact", "pact-python"] + +authors = [{ name = "Joshua Ellis", email = "josh@jpellis.me" }] +maintainers = [{ name = "Joshua Ellis", email = "josh@jpellis.me" }] + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Testing", +] + +requires-python = ">=3.10" + +dependencies = ["cffi~=2.0"] + + [project.urls] + "Bug Tracker" = "https://github.com/pact-foundation/pact-python/issues" + "Changelog" = "https://github.com/pact-foundation/pact-python/blob/main/pact-python-ffi/CHANGELOG.md" + "Documentation" = "https://docs.pact.io" + "Homepage" = "https://pact.io" + "Repository" = "https://github.com/pact-foundation/pact-python" + +[dependency-groups] +dev = ["ruff==0.15.13", { include-group = "test" }, { include-group = "types" }] +test = ["pytest~=9.0", "pytest-cov~=7.0"] +types = ["mypy==2.1.0", "typing-extensions~=4.0"] + +[build-system] +requires = ["cffi", "hatchling", "packaging", "setuptools"] +build-backend = "hatchling.build" + +[tool] + [tool.cibuildwheel] + environment.HATCH_VERBOSE = "1" + + [tool.cibuildwheel.linux] + before-build = ["uv pip install --system abi3audit"] + environment.PACT_LIB_DIR = "/tmp/pact_ffi" + repair-wheel-command = [ + "export LD_LIBRARY_PATH=\"$PACT_LIB_DIR:$LD_LIBRARY_PATH\"", + "auditwheel repair -w {dest_dir} {wheel}", + "abi3audit --strict --report {wheel}", + ] + + [tool.cibuildwheel.macos] + before-build = ["pip install abi3audit"] + environment.PACT_LIB_DIR = "/tmp/pact_ffi" + repair-wheel-command = [ + "export DYLD_LIBRARY_PATH=\"$PACT_LIB_DIR:$DYLD_LIBRARY_PATH\"", + "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}", + "abi3audit --strict --report {wheel}", + ] + + [tool.cibuildwheel.windows] + archs = ["auto64"] + before-build = ["pip install abi3audit delvewheel"] + environment.PACT_LIB_DIR = "C:/tmp/pact_ffi" + repair-wheel-command = [ + # ext-ms-* DLLs are no-export forwarding stubs present in System32 on all + # modern Windows. delvewheel 1.12.0 removed them from its no-bundle list + # (they have no exports so the author assumed nothing links to them), but + # they do appear in DLL import tables and cannot be bundled. Exclude the + # whole ext-ms-* family until upstream fixes ignore_regexes. + # See: https://github.com/adang1345/delvewheel/issues/ + "delvewheel repair -vv --add-path \"%PACT_LIB_DIR%\" --exclude \"ext-ms-*\" -w {dest_dir} {wheel}", + "abi3audit --strict --report {wheel}", + ] + + [[tool.cibuildwheel.overrides]] + environment.MACOSX_DEPLOYMENT_TARGET = "12.0" + inherit.environment = "append" + select = "*-macosx_x86_64" + + [[tool.cibuildwheel.overrides]] + environment.MACOSX_DEPLOYMENT_TARGET = "12.0" + inherit.environment = "append" + select = "*-macosx_arm64" + + [tool.coverage] + [tool.coverage.paths] + pact-ffi = ["/src/pact_ffi"] + tests = ["/tests"] + + [tool.coverage.report] + exclude_lines = [ + "@(abc\\.)?abstractmethod", # Ignore abstract methods + "if TYPE_CHECKING:", # Ignore typing + "if __name__ == .__main__.:", # Ignore non-runnable code + "raise NotImplementedError", # Ignore defensive assertions + ] + + [tool.hatch] + [tool.hatch.build] + packages = ["src/pact_ffi"] + + [tool.hatch.build.targets.wheel.hooks.custom] + patch = "hatch_build.py" + + [tool.hatch.envs] + [tool.hatch.envs.default] + installer = "uv" + path = ".venv" + extra-dependencies = ["hatchling", "packaging", "cffi"] + dependency-groups = ["dev"] + + [tool.hatch.envs.default.scripts] + all = ["format", "lint", "test", "typecheck"] + format = "ruff format {args}" + lint = "ruff check --show-fixes {args}" + test = "pytest tests/ {args}" + typecheck = ["typecheck-src", "typecheck-tests"] + typecheck-src = "mypy src/ {args}" + typecheck-tests = "mypy tests/ {args}" + + # Test environment for running unit tests. + [tool.hatch.envs.test] + installer = "uv" + path = ".venv/test" + dependency-groups = ["test"] + + [[tool.hatch.envs.test.matrix]] + python = ["3.10", "3.11", "3.12", "3.13", "3.14"] + + [tool.mypy] + # Overwrite the exclusions from the root pyproject.toml. + exclude = "" + + ## PyTest Configuration + [tool.pytest] + addopts = [ + # Coverage options + "--cov-config=pyproject.toml", + "--cov-report=xml", + "--cov=pact_ffi", + "--import-mode=importlib", + ] + + log_date_format = "%H:%M:%S" + log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" + log_level = "NOTSET" + + ## Ruff Configuration + [tool.ruff] + extend = "../pyproject.toml" + + exclude = [] diff --git a/pact-python-ffi/src/pact_ffi/__init__.py b/pact-python-ffi/src/pact_ffi/__init__.py new file mode 100644 index 000000000..228d6e4a7 --- /dev/null +++ b/pact-python-ffi/src/pact_ffi/__init__.py @@ -0,0 +1,8076 @@ +""" +Python bindings for the Pact FFI. + +This module provides a Python interface to the Pact FFI. It is a thin wrapper +around the C API, and is intended to be used by the Pact Python client library +to provide a Pythonic interface to Pact. + +/// warning +This module is not intended to be used directly by Pact users. Pact users should +use the Pact Python client library instead. No guarantees are made about the +stability of this module's API. +/// + + +## Developer Notes + +This modules should provide the following only: + +- Basic Enum classes +- Simple wrappers around functions, including the casting of input and output + values between the high level Python types and the low level C types. +- Simple wrappers around some of the low-level types. Specifically designed to + automatically handle the freeing of memory when the Python object is + destroyed. + +These low-level functions may then be combined into higher level classes and +modules. Ideally, all code outside of this module should be written in pure +Python and not worry about allocating or freeing memory. + +During initial implementation, a lot of these functions will simply raise a +[`NotImplementedError`][NotImplementedError]. + +For those unfamiliar with CFFI, please make sure to read the [CFFI +documentation](https://cffi.readthedocs.io/en/latest/using.html). + +### Handles + +The Rust library exposes a number of handles to internal data structures. This +is done to avoid exposing the internal implementation details of the library to +users of the library, and avoid unnecessarily casting to and from possibly +complicated structs. + +In the Rust library, the handles are thin wrappers around integers, and +unfortunately the CFFI interface sees this and automatically unwraps them, +exposing the underlying integer. As a result, we must re-wrap the integer +returned by the CFFI interface. This unfortunately means that we may be subject +to changes in private implementation details upstream. + +### Freeing Memory + +Python has a garbage collector, and as a result, we don't need to worry about +manually freeing memory. Having said that, Python's garbace collector is only +aware of Python objects, and not of any memory allocated by the Rust library. + +To ensure that the memory allocated by the Rust library is freed, we must make +sure to define the +[`__del__`](https://docs.python.org/3/reference/datamodel.html#object.__del__) +method to call the appropriate free function whenever the Python object is +destroyed. + +Note that there are some rather subtle details as to when this is called, when +it may never be called, and what global variables are accessible. This is +explained in the documentation for `__del__` above, and in Python's [garbage +collection](https://docs.python.org/3/library/gc.html) module. + +### Error Handling + +The FFI function should handle all errors raised by the function call, and raise +an appropriate Python exception. The exception should be raised using the +appropriate Python exception class, and should be documented in the function's +docstring. +""" + +# The following lints are disabled during initial development and should be +# removed later. +# ruff: noqa: ARG001 (unused-function-argument) +# ruff: noqa: A002 (builtin-argument-shadowing) +# ruff: noqa: D101 (undocumented-public-class) + +# The following lints are disabled for this file. +# ruff: noqa: SLF001 +# private-member-access, as we need access to other handles' internal +# references, without exposing them to the user. +# pyright: reportPrivateUsage=false +# Ignore private member access, as we frequently need to use the +# object's underlying pointer stored in `_ptr`. + +from __future__ import annotations + +import inspect +import json +import logging +import typing +import warnings +from enum import Enum +from typing import TYPE_CHECKING, Any, Literal + +from pact_ffi.__version__ import __version__ as __version__ +from pact_ffi.__version__ import __version_tuple__ as __version_tuple__ +from pact_ffi.ffi import ffi, lib # type: ignore[import] + +if TYPE_CHECKING: + import datetime + from collections.abc import Collection + from collections.abc import Generator as GeneratorType + from pathlib import Path + + import cffi + from typing_extensions import Self + +logger = logging.getLogger(__name__) + +################################################################################ +# Type aliases +################################################################################ +# The following type aliases provide a nicer interface for end-users of the +# library, especially when it comes to [`Enum`][Enum] classes which offers +# support for string literals as alternative values. + +GeneratorCategoryOptions = Literal[ + "METHOD", "method", + "PATH", "path", + "HEADER", "header", + "QUERY", "query", + "BODY", "body", + "STATUS", "status", + "METADATA", "metadata", +] # fmt: skip +""" +Generator Category Options. + +Type alias for the string literals which represent the Generator Category +Options. +""" + +MatchingRuleCategoryOptions = Literal[ + "METHOD", "method", + "PATH", "path", + "HEADER", "header", + "QUERY", "query", + "BODY", "body", + "STATUS", "status", + "CONTENTS", "contents", + "METADATA", "metadata", +] # fmt: skip + +################################################################################ +# Classes +################################################################################ +# The follow types are classes defined in the Rust code. Ultimately, a Python +# alternative should be implemented, but for now, the follow lines only serve +# to inform the type checker of the existence of these types. + + +class AsynchronousMessage: + def __init__(self, ptr: cffi.FFI.CData, *, owned: bool = False) -> None: + """ + Initialise a new Asynchronous Message. + + Args: + ptr: + CFFI data structure. + + owned: + Whether the message is owned by something else or not. This + determines whether the message should be freed when the Python + object is destroyed. + + Raises: + TypeError: + If the `ptr` is not a `struct AsynchronousMessage`. + """ + if ffi.typeof(ptr).cname != "struct AsynchronousMessage *": + msg = ( + f"ptr must be a struct AsynchronousMessage, got {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + self._owned = owned + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "AsynchronousMessage" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"AsynchronousMessage({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the AsynchronousMessage. + """ + if not self._owned: + async_message_delete(self) + + @property + def description(self) -> str: + """ + Description of this message interaction. + + This needs to be unique in the pact file. + """ + return async_message_get_description(self) + + def provider_states(self) -> GeneratorType[ProviderState, None, None]: + """ + Optional provider state for the interaction. + """ + yield from async_message_get_provider_state_iter(self) + return # Ensures that the parent object outlives the generator + + @property + def contents(self) -> MessageContents | None: + """ + The contents of the message. + + This may be `None` if the message has no contents. + """ + return async_message_generate_contents(self) + + +class Consumer: ... + + +class Generator: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a generator value. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct Generator`. + """ + if ffi.typeof(ptr).cname != "struct Generator *": + msg = f"ptr must be a struct Generator, got {ffi.typeof(ptr).cname}" + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "Generator" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"Generator({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Generator. + """ + + @property + def json(self) -> dict[str, Any]: + """ + Dictionary representation of the generator. + """ + return json.loads(generator_to_json(self)) + + def generate_string(self, context: dict[str, Any] | None = None) -> str: + """ + Generate a string from the generator. + + Args: + context: + JSON payload containing any generator context. For example: + + - The context for a `MockServerURL` generator should contain + details about the running mock server. + - The context for a `ProviderStateGenerator` should contain + the values returned from the provider state callback + function. + """ + return generator_generate_string(self, json.dumps(context or {})) + + def generate_integer(self, context: dict[str, Any] | None = None) -> int: + """ + Generate an integer from the generator. + + Args: + context: + JSON payload containing any generator context. For example: + + - The context for a `ProviderStateGenerator` should contain + the values returned from the provider state callback + function. + """ + return generator_generate_integer(self, json.dumps(context or {})) + + +class GeneratorCategoryIterator: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new generator category iterator. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct GeneratorCategoryIterator`. + """ + if ffi.typeof(ptr).cname != "struct GeneratorCategoryIterator *": + msg = ( + "ptr must be a struct GeneratorCategoryIterator, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "GeneratorCategoryIterator" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"GeneratorCategoryIterator({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the GeneratorCategoryIterator. + """ + generators_iter_delete(self) + + def __iter__(self) -> Self: + """ + Return the iterator itself. + """ + return self + + def __next__(self) -> GeneratorKeyValuePair: + """ + Get the next generator category from the iterator. + """ + return generators_iter_next(self) + + +class GeneratorKeyValuePair: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new key-value generator pair. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct GeneratorKeyValuePair`. + """ + if ffi.typeof(ptr).cname != "struct GeneratorKeyValuePair *": + msg = ( + "ptr must be a struct GeneratorKeyValuePair, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "GeneratorKeyValuePair" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"GeneratorKeyValuePair({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the GeneratorKeyValuePair. + """ + generators_iter_pair_delete(self) + + @property + def path(self) -> str: + """ + Generator path. + """ + s = ffi.string(self._ptr.path) # type: ignore[attr-defined] + if isinstance(s, bytes): + s = s.decode("utf-8") + return s + + @property + def generator(self) -> Generator: + """ + Generator value. + """ + return Generator(self._ptr.generator) # type: ignore[attr-defined] + + +class HttpRequest: ... + + +class HttpResponse: ... + + +class InteractionHandle: + """ + Handle to a HTTP Interaction. + + [Rust + `InteractionHandle`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/mock_server/handles/struct.InteractionHandle.html) + """ + + def __init__(self, ref: int) -> None: + """ + Initialise a new Interaction Handle. + + Args: + ref: + Reference to the Interaction Handle. + """ + self._ref: int = ref + + def __str__(self) -> str: + """ + String representation of the Interaction Handle. + """ + return f"InteractionHandle({self._ref})" + + def __repr__(self) -> str: + """ + String representation of the Interaction Handle. + """ + return f"InteractionHandle({self._ref!r})" + + +class MatchingRule: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new key-value generator pair. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct MatchingRule`. + """ + if ffi.typeof(ptr).cname != "struct MatchingRule *": + msg = f"ptr must be a struct MatchingRule, got {ffi.typeof(ptr).cname}" + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "MatchingRule" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"MatchingRule({self._ptr!r})" + + @property + def json(self) -> dict[str, Any]: + """ + Dictionary representation of the matching rule. + """ + return json.loads(matching_rule_to_json(self)) + + +class MatchingRuleCategoryIterator: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new key-value generator pair. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct MatchingRuleCategoryIterator`. + """ + if ffi.typeof(ptr).cname != "struct MatchingRuleCategoryIterator *": + msg = ( + "ptr must be a struct MatchingRuleCategoryIterator, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "MatchingRuleCategoryIterator" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"MatchingRuleCategoryIterator({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the MatchingRuleCategoryIterator. + """ + matching_rules_iter_delete(self) + + def __iter__(self) -> Self: + """ + Return the iterator itself. + """ + return self + + def __next__(self) -> MatchingRuleKeyValuePair: + """ + Get the next generator category from the iterator. + """ + return matching_rules_iter_next(self) + + +class MatchingRuleDefinitionResult: ... + + +class MatchingRuleIterator: ... + + +class MatchingRuleKeyValuePair: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new key-value generator pair. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct MatchingRuleKeyValuePair`. + """ + if ffi.typeof(ptr).cname != "struct MatchingRuleKeyValuePair *": + msg = ( + "ptr must be a struct MatchingRuleKeyValuePair, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "MatchingRuleKeyValuePair" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"MatchingRuleKeyValuePair({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the MatchingRuleKeyValuePair. + """ + matching_rules_iter_pair_delete(self) + + @property + def path(self) -> str: + """ + Matching Rule path. + """ + s = ffi.string(self._ptr.path) # type: ignore[attr-defined] + if isinstance(s, bytes): + s = s.decode("utf-8") + return s + + @property + def matching_rule(self) -> MatchingRule: + """ + Matching Rule value. + """ + return MatchingRule(self._ptr.matching_rule) # type: ignore[attr-defined] + + +class MatchingRuleResult: ... + + +class MessageContents: + def __init__(self, ptr: cffi.FFI.CData, *, owned: bool = True) -> None: + """ + Initialise a Message Contents. + + Args: + ptr: + CFFI data structure. + + owned: + Whether the message is owned by something else or not. This + determines whether the message should be freed when the Python + object is destroyed. + + Raises: + TypeError: + If the `ptr` is not a `struct MessageContents`. + """ + if ffi.typeof(ptr).cname != "struct MessageContents *": + msg = f"ptr must be a struct MessageContents, got {ffi.typeof(ptr).cname}" + raise TypeError(msg) + self._ptr = ptr + self._owned = owned + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "MessageContents" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"MessageContents({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the MessageContents. + """ + if not self._owned: + message_contents_delete(self) + + @property + def contents(self) -> str | bytes | None: + """ + Get the contents of the message. + """ + return message_contents_get_contents_str( + self + ) or message_contents_get_contents_bin(self) + + @property + def metadata(self) -> GeneratorType[MessageMetadataPair, None, None]: + """ + Get the metadata for the message contents. + """ + yield from message_contents_get_metadata_iter(self) + return # Ensures that the parent object outlives the generator + + def matching_rules( + self, + category: MatchingRuleCategoryOptions | MatchingRuleCategory, + ) -> GeneratorType[MatchingRuleKeyValuePair, None, None]: + """ + Get the matching rules for the message contents. + """ + if isinstance(category, str): + category = MatchingRuleCategory(category.upper()) + yield from message_contents_get_matching_rule_iter(self, category) + return # Ensures that the parent object outlives the generator + + def generators( + self, + category: GeneratorCategoryOptions | GeneratorCategory, + ) -> GeneratorType[GeneratorKeyValuePair, None, None]: + """ + Get the generators for the message contents. + """ + if isinstance(category, str): + category = GeneratorCategory(category.upper()) + yield from message_contents_get_generators_iter(self, category) + return # Ensures that the parent object outlives the generator + + +class MessageMetadataIterator: + """ + Iterator over an interaction's metadata. + """ + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Message Metadata Iterator. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct MessageMetadataIterator`. + """ + if ffi.typeof(ptr).cname != "struct MessageMetadataIterator *": + msg = ( + "ptr must be a struct MessageMetadataIterator, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "MessageMetadataIterator" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"MessageMetadataIterator({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Pact Interaction Iterator. + """ + message_metadata_iter_delete(self) + + def __iter__(self) -> Self: + """ + Return the iterator itself. + """ + return self + + def __next__(self) -> MessageMetadataPair: + """ + Get the next interaction from the iterator. + """ + return message_metadata_iter_next(self) + + +class MessageMetadataPair: + """ + A metadata key-value pair. + """ + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Message Metadata Pair. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct MessageMetadataPair`. + """ + if ffi.typeof(ptr).cname != "struct MessageMetadataPair *": + msg = ( + f"ptr must be a struct MessageMetadataPair, got {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "MessageMetadataPair" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"MessageMetadataPair({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Pact Interaction Iterator. + """ + message_metadata_pair_delete(self) + + @property + def key(self) -> str: + """ + Metadata key. + """ + s = ffi.string(self._ptr.key) # type: ignore[attr-defined] + if isinstance(s, bytes): + s = s.decode("utf-8") + return s + + @property + def value(self) -> str: + """ + Metadata value. + """ + s = ffi.string(self._ptr.value) # type: ignore[attr-defined] + if isinstance(s, bytes): + s = s.decode("utf-8") + return s + + +class Mismatch: ... + + +class Mismatches: ... + + +class MismatchesIterator: ... + + +class Pact: + def __init__(self, ptr: cffi.FFI.CData, *, owned: bool = True) -> None: + """ + Wrapper for a Pact model pointer. + + Args: + ptr: + CFFI pointer to `struct Pact *`. + + owned: + Whether the pact is owned by something else or not. This + determines whether the pact should be freed when the Python + object is destroyed. + + Raises: + TypeError: + If `ptr` is not a `struct Pact *`. + """ + if ffi.typeof(ptr).cname != "struct Pact *": + msg = f"ptr must be a struct Pact, got {ffi.typeof(ptr).cname}" + raise TypeError(msg) + self._ptr = ptr + self._owned = owned + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "Pact" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"Pact({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Pact. + """ + if not self._owned: + pact_model_delete(self) + + +class PactAsyncMessageIterator: + """ + Iterator over a Pact's asynchronous messages. + """ + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Pact Asynchronous Message Iterator. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct PactAsyncMessageIterator`. + """ + if ffi.typeof(ptr).cname != "struct PactAsyncMessageIterator *": + msg = ( + "ptr must be a struct PactAsyncMessageIterator, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "PactAsyncMessageIterator" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"PactAsyncMessageIterator({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Pact Synchronous Message Iterator. + """ + pact_async_message_iter_delete(self) + + def __iter__(self) -> Self: + """ + Return the iterator itself. + """ + return self + + def __next__(self) -> AsynchronousMessage: + """ + Get the next message from the iterator. + """ + return pact_async_message_iter_next(self) + + +class PactHandle: + """ + Handle to a Pact. + + [Rust + `PactHandle`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/mock_server/handles/struct.PactHandle.html) + """ + + def __init__(self, ref: int) -> None: + """ + Initialise a new Pact Handle. + + Args: + ref: + Rust library reference to the Pact Handle. + """ + self._ref: int = ref + + def __del__(self) -> None: + """ + Destructor for the Pact Handle. + """ + cleanup_plugins(self) + free_pact_handle(self) + + def __str__(self) -> str: + """ + String representation of the Pact Handle. + """ + return f"PactHandle({self._ref})" + + def __repr__(self) -> str: + """ + String representation of the Pact Handle. + """ + return f"PactHandle({self._ref!r})" + + def pointer(self) -> Pact: + """ + Unwrap the handle to access the underlying Pact model. + + This function clones the underlying structure, therefore any + modification to the original handle will not be reflected in the + returned Pact model, and vice versa. + + Returns: + The underlying Pact model. + """ + return pact_handle_to_pointer(self) + + +class PactServerHandle: + """ + Handle to a Pact Server. + + This does not have an exact correspondence in the Rust library. It is used + to manage the lifecycle of the mock server. + + # Implementation Notes + + The Rust library uses the port number as a unique identifier, in much the + same was as it uses a wrapped integer for the Pact handle. + """ + + def __init__(self, ref: int) -> None: + """ + Initialise a new Pact Server Handle. + + Args: + ref: + Rust library reference to the Pact Server. + """ + self._ref: int = ref + + def __del__(self) -> None: + """ + Destructor for the Pact Server Handle. + """ + cleanup_mock_server(self) + + def __str__(self) -> str: + """ + String representation of the Pact Server Handle. + """ + return f"PactServerHandle({self._ref})" + + def __repr__(self) -> str: + """ + String representation of the Pact Server Handle. + """ + return f"PactServerHandle({self._ref!r})" + + @property + def port(self) -> int: + """ + Port on which the Pact Server is running. + """ + return self._ref + + +class PactInteraction: + """ + A Pact Interaction. + + This is a minimal implementation to support iteration over all interactions. + Full support requires additional upstream library development. + """ + + def __init__(self, ptr: cffi.FFI.CData, *, owned: bool = False) -> None: + """ + Initialise a new Pact Interaction. + + Args: + ptr: + CFFI data structure. + + owned: + Whether the interaction is owned by something else or not. This + determines whether the interaction should be freed when the + Python object is destroyed. + """ + self._ptr = ptr + self._owned = owned + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "PactInteraction" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"PactInteraction({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Pact Interaction. + """ + if not self._owned: + pact_interaction_delete(self) + + def as_synchronous_http(self) -> SynchronousHttp: + """ + Cast this interaction as a synchronous HTTP interaction. + + Raises: + TypeError: + If the interaction is not a synchronous HTTP interaction. + """ + return pact_interaction_as_synchronous_http(self) + + def as_asynchronous_message(self) -> AsynchronousMessage: + """ + Cast this interaction as an asynchronous message interaction. + + Raises: + TypeError: + If the interaction is not an asynchronous message interaction. + """ + return pact_interaction_as_asynchronous_message(self) + + def as_synchronous_message(self) -> SynchronousMessage: + """ + Cast this interaction as a synchronous message interaction. + + Raises: + TypeError: + If the interaction is not a synchronous message interaction. + """ + return pact_interaction_as_synchronous_message(self) + + +class PactInteractionIterator: + """ + Iterator over a Pact's interactions. + + Interactions encompasses all types of interactions, including HTTP + interactions and messages. + """ + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Pact Interaction Iterator. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct PactInteractionIterator`. + """ + if ffi.typeof(ptr).cname != "struct PactInteractionIterator *": + msg = ( + "ptr must be a struct PactInteractionIterator, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "PactInteractionIterator" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"PactInteractionIterator({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Pact Interaction Iterator. + """ + pact_interaction_iter_delete(self) + + def __iter__(self) -> Self: + """ + Return the iterator itself. + """ + return self + + def __next__(self) -> PactInteraction: + """ + Get the next interaction from the iterator. + """ + return pact_interaction_iter_next(self) + + +class PactMessageIterator: + """ + Iterator over a Pact's interactions. + + Interactions encompasses all types of interactions, including HTTP + interactions and messages. + """ + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Pact Message Iterator. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct PactMessageIterator`. + """ + if ffi.typeof(ptr).cname != "struct PactMessageIterator *": + msg = ( + f"ptr must be a struct PactMessageIterator, got {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "PactMessageIterator" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"PactMessageIterator({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Pact Message Iterator. + """ + pact_message_iter_delete(self) + + def __iter__(self) -> Self: + """ + Return the iterator itself. + """ + return self + + def __next__(self) -> PactInteraction: + """ + Get the next interaction from the iterator. + """ + return pact_message_iter_next(self) + + +class PactSyncHttpIterator: + """ + Iterator over a Pact's synchronous HTTP interactions. + """ + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Pact Synchronous HTTP Iterator. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct PactSyncHttpIterator`. + """ + if ffi.typeof(ptr).cname != "struct PactSyncHttpIterator *": + msg = ( + "ptr must be a struct PactSyncHttpIterator, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "PactSyncHttpIterator" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"PactSyncHttpIterator({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Pact Synchronous HTTP Iterator. + """ + pact_sync_http_iter_delete(self) + + def __iter__(self) -> Self: + """ + Return the iterator itself. + """ + return self + + def __next__(self) -> SynchronousHttp: + """ + Get the next message from the iterator. + """ + return pact_sync_http_iter_next(self) + + +class PactSyncMessageIterator: + """ + Iterator over a Pact's synchronous messages. + """ + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Pact Synchronous Message Iterator. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct PactSyncMessageIterator`. + """ + if ffi.typeof(ptr).cname != "struct PactSyncMessageIterator *": + msg = ( + "ptr must be a struct PactSyncMessageIterator, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "PactSyncMessageIterator" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"PactSyncMessageIterator({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Pact Synchronous Message Iterator. + """ + pact_sync_message_iter_delete(self) + + def __iter__(self) -> Self: + """ + Return the iterator itself. + """ + return self + + def __next__(self) -> SynchronousMessage: + """ + Get the next message from the iterator. + """ + return pact_sync_message_iter_next(self) + + +class Provider: ... + + +class ProviderState: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new ProviderState. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct ProviderState`. + """ + if ffi.typeof(ptr).cname != "struct ProviderState *": + msg = f"ptr must be a struct ProviderState, got {ffi.typeof(ptr).cname}" + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "ProviderState({self.name!r})" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"ProviderState({self._ptr!r})" + + @property + def name(self) -> str: + """ + Provider State name. + """ + return provider_state_get_name(self) or "" + + def parameters(self) -> GeneratorType[tuple[str, str], None, None]: + """ + Provider State parameters. + + This is a generator that yields key-value pairs. + """ + for p in provider_state_get_param_iter(self): + yield p.key, p.value + return # Ensures that the parent object outlives the generator + + +class ProviderStateIterator: + """ + Iterator over an interactions ProviderStates. + """ + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Provider State Iterator. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct ProviderStateIterator`. + """ + if ffi.typeof(ptr).cname != "struct ProviderStateIterator *": + msg = ( + "ptr must be a struct ProviderStateIterator, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "ProviderStateIterator" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"ProviderStateIterator({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Provider State Iterator. + """ + provider_state_iter_delete(self) + + def __iter__(self) -> ProviderStateIterator: + """ + Return the iterator itself. + """ + return self + + def __next__(self) -> ProviderState: + """ + Get the next message from the iterator. + """ + return provider_state_iter_next(self) + + +class ProviderStateParamIterator: + """ + Iterator over a Provider States Parameters. + """ + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Provider State Param Iterator. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct ProviderStateParamIterator`. + """ + if ffi.typeof(ptr).cname != "struct ProviderStateParamIterator *": + msg = ( + "ptr must be a struct ProviderStateParamIterator, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "ProviderStateParamIterator" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"ProviderStateParamIterator({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Provider State Param Iterator. + """ + provider_state_param_iter_delete(self) + + def __iter__(self) -> ProviderStateParamIterator: + """ + Return the iterator itself. + """ + return self + + def __next__(self) -> ProviderStateParamPair: + """ + Get the next message from the iterator. + """ + return provider_state_param_iter_next(self) + + +class ProviderStateParamPair: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new ProviderStateParamPair. + + Args: + ptr: + CFFI data structure. + + Raises: + TypeError: + If the `ptr` is not a `struct ProviderStateParamPair`. + """ + if ffi.typeof(ptr).cname != "struct ProviderStateParamPair *": + msg = ( + "ptr must be a struct ProviderStateParamPair, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "ProviderStateParamPair" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"ProviderStateParamPair({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Provider State Param Pair. + """ + provider_state_param_pair_delete(self) + + @property + def key(self) -> str: + """ + Provider State Param key. + """ + s = ffi.string(self._ptr.key) # type: ignore[attr-defined] + if isinstance(s, bytes): + s = s.decode("utf-8") + return s + + @property + def value(self) -> str: + """ + Provider State Param value. + """ + s = ffi.string(self._ptr.value) # type: ignore[attr-defined] + if isinstance(s, bytes): + s = s.decode("utf-8") + return s + + +class SynchronousHttp: + def __init__(self, ptr: cffi.FFI.CData, *, owned: bool = False) -> None: + """ + Initialise a new Synchronous HTTP Interaction. + + Args: + ptr: + CFFI data structure. + + owned: + Whether the message is owned by something else or not. This + determines whether the message should be freed when the Python + object is destroyed. + + Raises: + TypeError: + If the `ptr` is not a `struct SynchronousHttp`. + """ + if ffi.typeof(ptr).cname != "struct SynchronousHttp *": + msg = f"ptr must be a struct SynchronousHttp, got {ffi.typeof(ptr).cname}" + raise TypeError(msg) + self._ptr = ptr + self._owned = owned + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "SynchronousHttp" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"SynchronousHttp({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the SynchronousHttp. + """ + if not self._owned: + sync_http_delete(self) + + @property + def description(self) -> str: + """ + Description of this message interaction. + + This needs to be unique in the pact file. + """ + return sync_http_get_description(self) + + def provider_states(self) -> GeneratorType[ProviderState, None, None]: + """ + Optional provider state for the interaction. + """ + yield from sync_http_get_provider_state_iter(self) + return # Ensures that the parent object outlives the generator + + @property + def request_contents(self) -> str | bytes | None: + """ + The contents of the request. + """ + return sync_http_get_request_contents( + self + ) or sync_http_get_request_contents_bin(self) + + @property + def response_contents(self) -> str | bytes | None: + """ + The contents of the response. + """ + return sync_http_get_response_contents( + self + ) or sync_http_get_response_contents_bin(self) + + +class SynchronousMessage: + def __init__(self, ptr: cffi.FFI.CData, *, owned: bool = False) -> None: + """ + Initialise a new Synchronous Message. + + Args: + ptr: + CFFI data structure. + + owned: + Whether the message is owned by something else or not. This + determines whether the message should be freed when the Python + object is destroyed. + + Raises: + TypeError: + If the `ptr` is not a `struct SynchronousMessage`. + """ + if ffi.typeof(ptr).cname != "struct SynchronousMessage *": + msg = ( + f"ptr must be a struct SynchronousMessage, got {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + self._owned = owned + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "SynchronousMessage" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"SynchronousMessage({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the SynchronousMessage. + """ + if not self._owned: + sync_message_delete(self) + + @property + def description(self) -> str: + """ + Description of this message interaction. + + This needs to be unique in the pact file. + """ + return sync_message_get_description(self) + + def provider_states(self) -> GeneratorType[ProviderState, None, None]: + """ + Optional provider state for the interaction. + """ + yield from sync_message_get_provider_state_iter(self) + return # Ensures that the parent object outlives the generator + + @property + def request_contents(self) -> MessageContents: + """ + The contents of the message. + """ + return sync_message_generate_request_contents(self) + + @property + def response_contents(self) -> GeneratorType[MessageContents, None, None]: + """ + The contents of the responses. + """ + yield from ( + sync_message_generate_response_contents(self, i) + for i in range(sync_message_get_number_responses(self)) + ) + return # Ensures that the parent object outlives the generator + + +class VerifierHandle: + """ + Handle to a Verifier. + + [Rust `VerifierHandle`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/verifier/handle/struct.VerifierHandle.html) + """ + + def __init__(self, ref: cffi.FFI.CData) -> None: + """ + Initialise a new Verifier Handle. + + Args: + ref: + Rust library reference to the Verifier Handle. + """ + self._ref = ref + + def __del__(self) -> None: + """ + Destructor for the Verifier Handle. + """ + verifier_shutdown(self) + + def __str__(self) -> str: + """ + String representation of the Verifier Handle. + """ + return f"VerifierHandle({hex(id(self._ref))})" + + def __repr__(self) -> str: + """ + String representation of the Verifier Handle. + """ + return f"" + + +class ExpressionValueType(Enum): + """ + Expression Value Type. + + [Rust `ExpressionValueType`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/models/expressions/enum.ExpressionValueType.html) + """ + + UNKNOWN = lib.ExpressionValueType_Unknown + STRING = lib.ExpressionValueType_String + NUMBER = lib.ExpressionValueType_Number + INTEGER = lib.ExpressionValueType_Integer + DECIMAL = lib.ExpressionValueType_Decimal + BOOLEAN = lib.ExpressionValueType_Boolean + + def __str__(self) -> str: + """ + Informal string representation of the Expression Value Type. + """ + return self.name + + def __repr__(self) -> str: + """ + Information-rich string representation of the Expression Value Type. + """ + return f"ExpressionValueType.{self.name}" + + +class GeneratorCategory(Enum): + """ + Generator Category. + + [Rust `GeneratorCategory`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/models/generators/enum.GeneratorCategory.html) + """ + + METHOD = lib.GeneratorCategory_METHOD + PATH = lib.GeneratorCategory_PATH + HEADER = lib.GeneratorCategory_HEADER + QUERY = lib.GeneratorCategory_QUERY + BODY = lib.GeneratorCategory_BODY + STATUS = lib.GeneratorCategory_STATUS + METADATA = lib.GeneratorCategory_METADATA + + def __str__(self) -> str: + """ + Informal string representation of the Generator Category. + """ + return self.name + + def __repr__(self) -> str: + """ + Information-rich string representation of the Generator Category. + """ + return f"GeneratorCategory.{self.name}" + + +class InteractionPart(Enum): + """ + Interaction Part. + + [Rust `InteractionPart`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/mock_server/handles/enum.InteractionPart.html) + """ + + REQUEST = lib.InteractionPart_Request + RESPONSE = lib.InteractionPart_Response + + def __str__(self) -> str: + """ + Informal string representation of the Interaction Part. + """ + return self.name + + def __repr__(self) -> str: + """ + Information-rich string representation of the Interaction Part. + """ + return f"InteractionPath.{self.name}" + + +class LevelFilter(Enum): + """Level Filter.""" + + OFF = lib.LevelFilter_Off + ERROR = lib.LevelFilter_Error + WARN = lib.LevelFilter_Warn + INFO = lib.LevelFilter_Info + DEBUG = lib.LevelFilter_Debug + TRACE = lib.LevelFilter_Trace + + def __str__(self) -> str: + """ + Informal string representation of the Level Filter. + """ + return self.name + + def __repr__(self) -> str: + """ + Information-rich string representation of the Level Filter. + """ + return f"LevelFilter.{self.name}" + + +class MatchingRuleCategory(Enum): + """ + Matching Rule Category. + + [Rust `MatchingRuleCategory`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/models/matching_rules/enum.MatchingRuleCategory.html) + """ + + METHOD = lib.MatchingRuleCategory_METHOD + PATH = lib.MatchingRuleCategory_PATH + HEADER = lib.MatchingRuleCategory_HEADER + QUERY = lib.MatchingRuleCategory_QUERY + BODY = lib.MatchingRuleCategory_BODY + STATUS = lib.MatchingRuleCategory_STATUS + CONTENTS = lib.MatchingRuleCategory_CONTENTS + METADATA = lib.MatchingRuleCategory_METADATA + + def __str__(self) -> str: + """ + Informal string representation of the Matching Rule Category. + """ + return self.name + + def __repr__(self) -> str: + """ + Information-rich string representation of the Matching Rule Category. + """ + return f"MatchingRuleCategory.{self.name}" + + +class PactSpecification(Enum): + """ + Pact Specification. + + [Rust `PactSpecification`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/models/pact_specification/enum.PactSpecification.html) + """ + + UNKNOWN = lib.PactSpecification_Unknown + V1 = lib.PactSpecification_V1 + V1_1 = lib.PactSpecification_V1_1 + V2 = lib.PactSpecification_V2 + V3 = lib.PactSpecification_V3 + V4 = lib.PactSpecification_V4 + + @classmethod + def from_str(cls, version: str) -> PactSpecification: + """ + Instantiate a Pact Specification from a string. + + This method is case-insensitive, and allows for the version to be + specified with or without a leading "V", and with either a dot or an + underscore as the separator. + + Args: + version: + The version of the Pact Specification. + + Returns: + The Pact Specification. + """ + version = version.upper().replace(".", "_") + if version.startswith("V"): + return cls[version] + return cls["V" + version] + + def __str__(self) -> str: + """ + Informal string representation of the Pact Specification. + """ + return self.name + + def __repr__(self) -> str: + """ + Information-rich string representation of the Pact Specification. + """ + return f"PactSpecification.{self.name}" + + +class StringResult: + """ + String result. + """ + + class _StringResult(Enum): + """ + Internal enum from Pact FFI. + + [Rust `StringResult`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/mock_server/enum.StringResult.html) + """ + + FAILED = lib.StringResult_Failed + OK = lib.StringResult_Ok + + class _StringResultCData: + tag: int + ok: cffi.FFI.CData + failed: cffi.FFI.CData + + def __init__(self, cdata: cffi.FFI.CData) -> None: + """ + Initialise a new String Result. + + Args: + cdata: + CFFI data structure. + + Raises: + TypeError: + If the `cdata` is not a `struct StringResult`. + """ + if ffi.typeof(cdata).cname != "struct StringResult": + msg = f"cdata must be a struct StringResult, got {ffi.typeof(cdata).cname}" + raise TypeError(msg) + self._cdata = typing.cast("StringResult._StringResultCData", cdata) + + def __str__(self) -> str: + """ + String representation of the String Result. + """ + return self.text + + def __repr__(self) -> str: + """ + Debugging string representation of the String Result. + """ + return f"" + + @property + def is_failed(self) -> bool: + """ + Whether the result is an error. + """ + return self._cdata.tag == StringResult._StringResult.FAILED.value + + @property + def is_ok(self) -> bool: + """ + Whether the result is ok. + """ + return self._cdata.tag == StringResult._StringResult.OK.value + + @property + def text(self) -> str: + """ + The text of the result. + """ + # The specific `.ok` or `.failed` does not matter. + s = ffi.string(self._cdata.ok) + if isinstance(s, bytes): + return s.decode("utf-8") + return s + + def raise_exception(self) -> None: + """ + Raise an exception with the text of the result. + + Raises: + RuntimeError: + If the result is an error. + + Raises: + RuntimeError: + If the result is an error. + """ + if self.is_failed: + raise RuntimeError(self.text) + + +class OwnedString(str): + """ + A string that owns its own memory. + + This is used to ensure that the memory is freed when the string is + destroyed. + + As this is subclassed from `str`, it can be used in place of a normal string + in most cases. + """ + + __slots__ = ("_ptr", "_string") + + def __new__(cls, ptr: cffi.FFI.CData) -> Self: + """ + Create a new Owned String. + + As this is a subclass of the immutable type `str`, we need to override + the `__new__` method to ensure that the string is initialised correctly. + """ + s = ffi.string(ptr) + return super().__new__(cls, s if isinstance(s, str) else s.decode("utf-8")) + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Owned String. + + Args: + ptr: + CFFI data structure. + """ + self._ptr = ptr + s = ffi.string(ptr) + self._string = s if isinstance(s, str) else s.decode("utf-8") + + def __str__(self) -> str: + """ + String representation of the Owned String. + """ + return self._string + + def __repr__(self) -> str: + """ + Debugging string representation of the Owned String. + """ + return f"" + + def __del__(self) -> None: + """ + Destructor for the Owned String. + """ + string_delete(self) + + def __eq__(self, other: object) -> bool: + """ + Equality comparison. + + Args: + other: + The object to compare to. + + Returns: + Whether the two objects are equal. + """ + if isinstance(other, OwnedString): + return self._ptr == other._ptr + if isinstance(other, str): + return self._string == other + return super().__eq__(other) + + def __hash__(self) -> int: + """ + Hash the Owned String. + + Returns: + The hash of the Owned String. + """ + return hash(self._string) + + +def version() -> str: + """ + Return the version of the pact_ffi library. + + [Rust `pactffi_version`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_version) + + Returns: + The version of the pact_ffi library as a string, in the form of `x.y.z`. + """ + v = ffi.string(lib.pactffi_version()) + if isinstance(v, bytes): + return v.decode("utf-8") + return v + + +def init(log_env_var: str) -> None: + """ + Initialise the Pact FFI mock server library. + + This can provide an environment variable name to use to set the log levels. + This function should only be called once, as it tries to install a global + tracing subscriber. + + [Rust + `pactffi_init`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_init) + + Args: + log_env_var: + Name of the environment variable that controls Pact logging levels. + + # Safety + + log_env_var must be a valid NULL terminated UTF-8 string. + """ + raise NotImplementedError + + +def init_with_log_level(level: str = "INFO") -> None: + """ + Initialises logging and sets the Pact FFI log level explicitly. + + This function should only be called once, as it tries to install a global + tracing subscriber. + + [Rust + `pactffi_init_with_log_level`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_init_with_log_level) + + Args: + level: + One of TRACE, DEBUG, INFO, WARN, ERROR, NONE/OFF. Case-insensitive. + + # Safety + + Exported functions are inherently unsafe. + """ + raise NotImplementedError + + +def enable_ansi_support() -> None: + """ + Enable ANSI coloured output on Windows. + + On non-Windows platforms, this function is a no-op. + + [Rust + `pactffi_enable_ansi_support`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_enable_ansi_support) + + # Safety + + This function is safe. + """ + raise NotImplementedError + + +def log_message( + message: str, + log_level: LevelFilter | str = LevelFilter.ERROR, + source: str | None = None, +) -> None: + """ + Log using the shared core logging facility. + + [Rust + `pactffi_log_message`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_log_message) + + This is useful for callers to have a single set of logs. + + Args: + message: + The contents written to the log. + + log_level: + The verbosity at which this message should be logged. + + source: + The source of the log, such as the class, module or caller. + """ + if isinstance(log_level, str): + log_level = LevelFilter[log_level.upper()] + if source is None: + source = inspect.stack()[1].function + lib.pactffi_log_message( + source.encode("utf-8"), + log_level.name.encode("utf-8"), + message.encode("utf-8"), + ) + + +def mismatches_get_iter(mismatches: Mismatches) -> MismatchesIterator: + """ + Get an iterator over mismatches. + + [Rust + `pactffi_mismatches_get_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mismatches_get_iter) + """ + raise NotImplementedError + + +def mismatches_delete(mismatches: Mismatches) -> None: + """ + Delete mismatches. + + [Rust `pactffi_mismatches_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mismatches_delete) + """ + raise NotImplementedError + + +def mismatches_iter_next(iter: MismatchesIterator) -> Mismatch: + """ + Get the next mismatch from a mismatches iterator. + + [Rust `pactffi_mismatches_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mismatches_iter_next) + + Returns a null pointer if no mismatches remain. + """ + raise NotImplementedError + + +def mismatches_iter_delete(iter: MismatchesIterator) -> None: + """ + Delete a mismatches iterator when you're done with it. + + [Rust `pactffi_mismatches_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mismatches_iter_delete) + """ + raise NotImplementedError + + +def mismatch_to_json(mismatch: Mismatch) -> str: + """ + Get a JSON representation of the mismatch. + + [Rust `pactffi_mismatch_to_json`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mismatch_to_json) + """ + raise NotImplementedError + + +def mismatch_type(mismatch: Mismatch) -> str: + """ + Get the type of a mismatch. + + [Rust `pactffi_mismatch_type`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mismatch_type) + """ + raise NotImplementedError + + +def mismatch_summary(mismatch: Mismatch) -> str: + """ + Get a summary of a mismatch. + + [Rust `pactffi_mismatch_summary`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mismatch_summary) + """ + raise NotImplementedError + + +def mismatch_description(mismatch: Mismatch) -> str: + """ + Get a description of a mismatch. + + [Rust `pactffi_mismatch_description`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mismatch_description) + """ + raise NotImplementedError + + +def mismatch_ansi_description(mismatch: Mismatch) -> str: + """ + Get an ANSI-compatible description of a mismatch. + + [Rust `pactffi_mismatch_ansi_description`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mismatch_ansi_description) + """ + raise NotImplementedError + + +def get_error_message(length: int = 1024) -> str | None: + """ + Provide the error message from `LAST_ERROR` to the calling C code. + + [Rust + `pactffi_get_error_message`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_get_error_message) + + This function should be called after any other function in the pact_matching + FFI indicates a failure with its own error message, if the caller wants to + get more context on why the error happened. + + Do note that this error-reporting mechanism only reports the top-level error + message, not any source information embedded in the original Rust error + type. If you want more detailed information for debugging purposes, use the + logging interface. + + Args: + length: + The length of the buffer to allocate for the error message. If the + error message is longer than this, it will be truncated. + + Returns: + A string containing the error message, or None if there is no error + message. + + Raises: + RuntimeError: + If the error message could not be retrieved. + """ + buffer = ffi.new("char[]", length) + ret: int = lib.pactffi_get_error_message(buffer, length) + + if ret >= 0: + # While the documentation says that the return value is the number of bytes + # written, the actually return value is always 0 on success. + if msg := ffi.string(buffer): + if isinstance(msg, bytes): + return msg.decode("utf-8") + return msg + return None + if ret == -1: + msg = "The provided buffer is a null pointer." + elif ret == -2: # noqa: PLR2004 + # Instead of returning an error here, we call the function again with a + # larger buffer. + return get_error_message(length * 32) + elif ret == -3: # noqa: PLR2004 + msg = "The write failed for some other reason." + elif ret == -4: # noqa: PLR2004 + msg = "The error message had an interior NULL." + else: + msg = "An unknown error occurred." + raise RuntimeError(msg) + + +def log_to_stdout(level_filter: LevelFilter) -> int: + """ + Convenience function to direct all logging to stdout. + + This function is equivalent to using [`logger_init`] followed by the + [`logger_attach_sink`] with the appropriate sink specifier, and then + [`logger_apply`]. + + [Rust `pactffi_log_to_stdout`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_log_to_stdout) + """ + raise NotImplementedError + + +def log_to_stderr(level_filter: LevelFilter | str = LevelFilter.ERROR) -> None: + """ + Convenience function to direct all logging to stderr. + + This function is equivalent to using [`logger_init`] followed by the + [`logger_attach_sink`] with the appropriate sink specifier, and then + [`logger_apply`]. + + [Rust + `pactffi_log_to_stderr`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_log_to_stderr) + + Args: + level_filter: + The level of logs to filter to. If a string is given, it must match + one of the [`LevelFilter`][pact_ffi.LevelFilter] values (case + insensitive). + + Raises: + RuntimeError: + If there was an error setting the logger. + """ + if isinstance(level_filter, str): + level_filter = LevelFilter[level_filter.upper()] + ret: int = lib.pactffi_log_to_stderr(level_filter.value) + if ret != 0: + msg = "There was an unknown error setting the logger." + raise RuntimeError(msg) + + +def log_to_file(file_name: str, level_filter: LevelFilter) -> int: + """ + Convenience function to direct all logging to a file. + + This function is equivalent to using [`logger_init`] followed by the + [`logger_attach_sink`] with the appropriate sink specifier, and then + [`logger_apply`]. + + [Rust + `pactffi_log_to_file`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_log_to_file) + + # Safety + + This function will fail if the file_name pointer is invalid or does not + point to a NULL terminated string. + """ + raise NotImplementedError + + +def log_to_buffer(level_filter: LevelFilter | str = LevelFilter.ERROR) -> None: + """ + Convenience function to direct all logging to a task local memory buffer. + + This function is equivalent to using [`logger_init`] followed by the + [`logger_attach_sink`] with the appropriate sink specifier, and then + [`logger_apply`]. + + [Rust `pactffi_log_to_buffer`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_log_to_buffer) + + Raises: + RuntimeError: + If there was an error setting the logger. + """ + if isinstance(level_filter, str): + level_filter = LevelFilter[level_filter.upper()] + ret: int = lib.pactffi_log_to_buffer(level_filter.value) + if ret != 0: + msg = "There was an unknown error setting the logger." + raise RuntimeError(msg) + + +def logger_init() -> None: + """ + Initialize the FFI logger with no sinks. + + [Rust `pactffi_logger_init`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_logger_init) + + This initialized logger does nothing until `pactffi_logger_apply` has been called. + + # Usage + + ```c + pactffi_logger_init(); + ``` + + # Safety + + This function is always safe to call. + """ + raise NotImplementedError + + +def logger_attach_sink(sink_specifier: str, level_filter: LevelFilter) -> int: + """ + Attach an additional sink to the thread-local logger. + + [Rust + `pactffi_logger_attach_sink`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_logger_attach_sink) + + This logger does nothing until `pactffi_logger_apply` has been called. + + Types of sinks can be specified: + + - stdout (`pactffi_logger_attach_sink("stdout", LevelFilter_Info)`) + - stderr (`pactffi_logger_attach_sink("stderr", LevelFilter_Debug)`) + - file w/ file path (`pactffi_logger_attach_sink("file /some/file/path", + LevelFilter_Trace)`) + - buffer (`pactffi_logger_attach_sink("buffer", LevelFilter_Debug)`) + + # Usage + + ```c + int result = pactffi_logger_attach_sink("file /some/file/path", LogLevel_Filter); + ``` + + # Error Handling + + The return error codes are as follows: + + - `-1`: Can't set logger (applying the logger failed, perhaps because one is + applied already). + - `-2`: No logger has been initialized (call `pactffi_logger_init` before + any other log function). + - `-3`: The sink specifier was not UTF-8 encoded. + - `-4`: The sink type specified is not a known type (known types: "stdout", + "stderr", "buffer", or "file /some/path"). + - `-5`: No file path was specified in a file-type sink specification. + - `-6`: Opening a sink to the specified file path failed (check + permissions). + + # Safety + + This function checks the validity of the passed-in sink specifier, and + errors out if the specifier isn't valid UTF-8. Passing in an invalid or NULL + pointer will result in undefined behaviour. + """ + raise NotImplementedError + + +def logger_apply() -> int: + """ + Apply the previously configured sinks and levels to the program. + + [Rust + `pactffi_logger_apply`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_logger_apply) + + If no sinks have been setup, will set the log level to info and the target + to standard out. + + This function will install a global tracing subscriber. Any attempts to + modify the logger after the call to `logger_apply` will fail. + """ + raise NotImplementedError + + +def fetch_log_buffer(log_id: str) -> str: + """ + Fetch the in-memory logger buffer contents. + + [Rust + `pactffi_fetch_log_buffer`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_fetch_log_buffer) + + This will only have any contents if the `buffer` sink has been configured to + log to. The contents will be allocated on the heap and will need to be freed + with `pactffi_string_delete`. + + Fetches the logs associated with the provided identifier, or uses the + "global" one if the identifier is not specified (i.e. NULL). + + Returns a NULL pointer if the buffer can't be fetched. This can occur is + there is not sufficient memory to make a copy of the contents or the buffer + contains non-UTF-8 characters. + + # Safety + + This function will fail if the log_id pointer is invalid or does not point + to a NULL terminated string. + """ + raise NotImplementedError + + +def parse_pact_json(json: str) -> Pact: + """ + Parses the provided JSON into a Pact model. + + [Rust + `pactffi_parse_pact_json`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_parse_pact_json) + + The returned Pact model must be freed with the `pactffi_pact_model_delete` + function when no longer needed. + + # Error Handling + + This function will return a NULL pointer if passed a NULL pointer or if an + error occurs. + """ + raise NotImplementedError + + +def pact_model_delete(pact: Pact) -> None: + """ + Frees the memory used by the Pact model. + + [Rust `pactffi_pact_model_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_model_delete) + """ + lib.pactffi_pact_model_delete(pact._ptr) + + +def pact_model_interaction_iterator(pact: Pact) -> PactInteractionIterator: + """ + Returns an iterator over all the interactions in the Pact. + + [Rust + `pactffi_pact_model_interaction_iterator`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_model_interaction_iterator) + + The iterator will contain a copy of the interactions, so it will not be + affected but mutations to the Pact model and will still function if the Pact + model is deleted. + + Args: + pact: + The Pact model. + + Returns: + An iterator over all interactions in the Pact. + """ + return PactInteractionIterator( + lib.pactffi_pact_model_interaction_iterator(pact._ptr) + ) + + +def pact_spec_version(pact: Pact) -> PactSpecification: + """ + Returns the Pact specification enum that the Pact is for. + + [Rust `pactffi_pact_spec_version`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_spec_version) + """ + raise NotImplementedError + + +def pact_interaction_delete(interaction: PactInteraction) -> None: + """ + Frees the memory used by the Pact interaction model. + + [Rust `pactffi_pact_interaction_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_interaction_delete) + """ + lib.pactffi_pact_interaction_delete(interaction._ptr) + + +def async_message_new() -> AsynchronousMessage: + """ + Get a mutable pointer to a newly-created default message on the heap. + + [Rust `pactffi_async_message_new`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_new) + + # Safety + + This function is safe. + + # Error Handling + + Returns NULL on error. + """ + raise NotImplementedError + + +def async_message_delete(message: AsynchronousMessage) -> None: + """ + Destroy the `AsynchronousMessage` being pointed to. + + [Rust `pactffi_async_message_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_delete) + """ + lib.pactffi_async_message_delete(message._ptr) + + +def async_message_get_contents(message: AsynchronousMessage) -> MessageContents | None: + """ + Get the message contents of an `AsynchronousMessage` as a `MessageContents` pointer. + + [Rust + `pactffi_async_message_get_contents`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_get_contents) + + If the message contents are missing, this function will return `None`. + """ + return MessageContents(lib.pactffi_async_message_get_contents(message._ptr)) + + +def async_message_generate_contents( + message: AsynchronousMessage, +) -> MessageContents | None: + """ + Get the message contents of an `AsynchronousMessage` as a `MessageContents` pointer. + + This function differs from `async_message_get_contents` in + that it will process the message contents for any generators or matchers + that are present in the message in order to generate the actual message + contents as would be received by the consumer. + + [Rust + `pactffi_async_message_generate_contents`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_generate_contents) + + If the message contents are missing, this function will return `None`. + """ + return MessageContents( + lib.pactffi_async_message_generate_contents(message._ptr), + owned=False, + ) + + +def async_message_get_contents_str(message: AsynchronousMessage) -> str: + """ + Get the message contents of an `AsynchronousMessage` in string form. + + [Rust `pactffi_async_message_get_contents_str`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_get_contents_str) + + # Safety + + The returned string must be deleted with `pactffi_string_delete`. + + The returned string can outlive the message. + + # Error Handling + + If the message is NULL, returns NULL. If the body of the message + is missing, then this function also returns NULL. This means there's + no mechanism to differentiate with this function call alone between + a NULL message and a missing message body. + """ + raise NotImplementedError + + +def async_message_set_contents_str( + message: AsynchronousMessage, + contents: str, + content_type: str, +) -> None: + """ + Sets the contents of the message as a string. + + [Rust + `pactffi_async_message_set_contents_str`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_set_contents_str) + + - `message` - the message to set the contents for + - `contents` - pointer to contents to copy from. Must be a valid + NULL-terminated UTF-8 string pointer. + - `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The message contents and content type must either be NULL pointers, or point + to valid UTF-8 encoded NULL-terminated strings. Otherwise behaviour is + undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the message contents as null. + If the content type is a null pointer, or can't be parsed, it will set the + content type as unknown. + """ + raise NotImplementedError + + +def async_message_get_contents_length(message: AsynchronousMessage) -> int: + """ + Get the length of the contents of a `AsynchronousMessage`. + + [Rust + `pactffi_async_message_get_contents_length`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_get_contents_length) + + # Safety + + This function is safe. + + # Error Handling + + If the message is NULL, returns 0. If the body of the request is missing, + then this function also returns 0. + """ + raise NotImplementedError + + +def async_message_get_contents_bin(message: AsynchronousMessage) -> str: + """ + Get the contents of an `AsynchronousMessage` as bytes. + + [Rust + `pactffi_async_message_get_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_get_contents_bin) + + # Safety + + The number of bytes in the buffer will be returned by + `pactffi_async_message_get_contents_length`. It is safe to use the pointer + while the message is not deleted or changed. Using the pointer after the + message is mutated or deleted may lead to undefined behaviour. + + # Error Handling + + If the message is NULL, returns NULL. If the body of the message is missing, + then this function also returns NULL. + """ + raise NotImplementedError + + +def async_message_set_contents_bin( + message: AsynchronousMessage, + contents: str, + len: int, + content_type: str, +) -> None: + """ + Sets the contents of the message as an array of bytes. + + [Rust + `pactffi_async_message_set_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_set_contents_bin) + + * `message` - the message to set the contents for + * `contents` - pointer to contents to copy from + * `len` - number of bytes to copy from the contents pointer + * `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The contents pointer must be valid for reads of `len` bytes, and it must be + properly aligned and consecutive. Otherwise behaviour is undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the message contents as null. + If the content type is a null pointer, or can't be parsed, it will set the + content type as unknown. + """ + raise NotImplementedError + + +def async_message_get_description(message: AsynchronousMessage) -> str: + r""" + Get a copy of the description. + + [Rust + `pactffi_async_message_get_description`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_get_description) + + Raises: + RuntimeError: + If the description cannot be retrieved. + """ + ptr = lib.pactffi_async_message_get_description(message._ptr) + if ptr == ffi.NULL: + msg = "Unable to get the description from the message." + raise RuntimeError(msg) + return OwnedString(ptr) + + +def async_message_set_description( + message: AsynchronousMessage, + description: str, +) -> int: + """ + Write the `description` field on the `AsynchronousMessage`. + + [Rust `pactffi_async_message_set_description`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_set_description) + + # Safety + + `description` must contain valid UTF-8. Invalid UTF-8 + will be replaced with U+FFFD REPLACEMENT CHARACTER. + + This function will only reallocate if the new string + does not fit in the existing buffer. + + # Error Handling + + Errors will be reported with a non-zero return value. + """ + raise NotImplementedError + + +def async_message_get_provider_state( + message: AsynchronousMessage, + index: int, +) -> ProviderState: + r""" + Get a copy of the provider state at the given index from this message. + + [Rust + `pactffi_async_message_get_provider_state`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_get_provider_state) + + Raises: + RuntimeError: + If the provider state cannot be retrieved. + """ + ptr = lib.pactffi_async_message_get_provider_state(message._ptr, index) + if ptr == ffi.NULL: + msg = "Unable to get the provider state from the message." + raise RuntimeError(msg) + return ProviderState(ptr) + + +def async_message_get_provider_state_iter( + message: AsynchronousMessage, +) -> ProviderStateIterator: + """ + Get an iterator over provider states. + + [Rust `pactffi_async_message_get_provider_state_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_async_message_get_provider_state_iter) + + # Safety + + The underlying data must not change during iteration. + """ + return ProviderStateIterator( + lib.pactffi_async_message_get_provider_state_iter(message._ptr) + ) + + +def consumer_get_name(consumer: Consumer) -> str: + r""" + Get a copy of this consumer's name. + + [Rust `pactffi_consumer_get_name`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_consumer_get_name) + + The copy must be deleted with `pactffi_string_delete`. + + # Usage + + ```c + // Assuming `file_name` and `json_str` are already defined. + + MessagePact *message_pact = pactffi_message_pact_new_from_json(file_name, json_str); + if (message_pact == NULLPTR) { + // handle error. + } + + Consumer *consumer = pactffi_message_pact_get_consumer(message_pact); + if (consumer == NULLPTR) { + // handle error. + } + + char *name = pactffi_consumer_get_name(consumer); + if (name == NULL) { + // handle error. + } + + printf("%s\n", name); + + pactffi_string_delete(name); + ``` + + # Errors + + This function will fail if it is passed a NULL pointer, + or the Rust string contains an embedded NULL byte. + In the case of error, a NULL pointer will be returned. + """ + raise NotImplementedError + + +def pact_get_consumer(pact: Pact) -> Consumer: + """ + Get the consumer from a Pact. + + This returns a copy of the consumer model, and needs to be cleaned up with + `pactffi_pact_consumer_delete` when no longer required. + + [Rust + `pactffi_pact_get_consumer`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_get_consumer) + + # Errors + + This function will fail if it is passed a NULL pointer. In the case of + error, a NULL pointer will be returned. + """ + raise NotImplementedError + + +def pact_consumer_delete(consumer: Consumer) -> None: + """ + Frees the memory used by the Pact consumer. + + [Rust `pactffi_pact_consumer_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_consumer_delete) + """ + raise NotImplementedError + + +def message_contents_delete(contents: MessageContents) -> None: + """ + Delete the message contents instance. + + This should only be called on a message contents that require deletion. + The function creating the message contents should document whether it + requires deletion. + + Deleting a message content which is associated with an interaction + will result in undefined behaviour. + + [Rust `pactffi_message_contents_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_contents_delete) + """ + lib.pactffi_message_contents_delete(contents._ptr) + + +def message_contents_get_contents_str(contents: MessageContents) -> str | None: + """ + Get the message contents in string form. + + [Rust `pactffi_message_contents_get_contents_str`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_contents_get_contents_str) + + If the message has no contents or contain invalid UTF-8 characters, this + function will return `None`. + """ + ptr = lib.pactffi_message_contents_get_contents_str(contents._ptr) + if ptr == ffi.NULL: + return None + return OwnedString(ptr) + + +def message_contents_set_contents_str( + contents: MessageContents, + contents_str: str, + content_type: str, +) -> None: + """ + Sets the contents of the message as a string. + + [Rust + `pactffi_message_contents_set_contents_str`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_contents_set_contents_str) + + * `contents` - the message contents to set the contents for + * `contents_str` - pointer to contents to copy from. Must be a valid + NULL-terminated UTF-8 string pointer. + * `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The message contents and content type must either be NULL pointers, or point + to valid UTF-8 encoded NULL-terminated strings. Otherwise behaviour is + undefined. + + # Error Handling + + If the contents string is a NULL pointer, it will set the message contents + as null. If the content type is a null pointer, or can't be parsed, it will + set the content type as unknown. + """ + raise NotImplementedError + + +def message_contents_get_contents_length(contents: MessageContents) -> int: + """ + Get the length of the message contents. + + [Rust `pactffi_message_contents_get_contents_length`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_contents_get_contents_length) + + If the message has not contents, this function will return 0. + """ + return lib.pactffi_message_contents_get_contents_length(contents._ptr) + + +def message_contents_get_contents_bin(contents: MessageContents) -> bytes | None: + """ + Get the contents of a message as a pointer to an array of bytes. + + [Rust + `pactffi_message_contents_get_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_contents_get_contents_bin) + + If the message has no contents, this function will return `None`. + """ + ptr = lib.pactffi_message_contents_get_contents_bin(contents._ptr) + if ptr == ffi.NULL: + return None + return ffi.buffer( + ptr, + lib.pactffi_message_contents_get_contents_length(contents._ptr), + )[:] + + +def message_contents_set_contents_bin( + contents: MessageContents, + contents_bin: str, + len: int, + content_type: str, +) -> None: + """ + Sets the contents of the message as an array of bytes. + + [Rust + `pactffi_message_contents_set_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_contents_set_contents_bin) + + * `message` - the message contents to set the contents for + * `contents_bin` - pointer to contents to copy from + * `len` - number of bytes to copy from the contents pointer + * `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The contents pointer must be valid for reads of `len` bytes, and it must be + properly aligned and consecutive. Otherwise behaviour is undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the message contents as null. + If the content type is a null pointer, or can't be parsed, it will set the + content type as unknown. + """ + raise NotImplementedError + + +def message_contents_get_metadata_iter( + contents: MessageContents, +) -> MessageMetadataIterator: + r""" + Get an iterator over the metadata of a message. + + [Rust + `pactffi_message_contents_get_metadata_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_contents_get_metadata_iter) + + # Safety + + This iterator carries a pointer to the message contents, and must not + outlive the message. + + The message metadata also must not be modified during iteration. If it is, + the old iterator must be deleted and a new iterator created. + + Raises: + RuntimeError: + If the metadata iterator cannot be retrieved. + """ + ptr = lib.pactffi_message_contents_get_metadata_iter(contents._ptr) + if ptr == ffi.NULL: + msg = "Unable to get the metadata iterator from the message contents." + raise RuntimeError(msg) + return MessageMetadataIterator(ptr) + + +def message_contents_get_matching_rule_iter( + contents: MessageContents, + category: MatchingRuleCategory, +) -> MatchingRuleCategoryIterator: + r""" + Get an iterator over the matching rules for a category of a message. + + [Rust + `pactffi_message_contents_get_matching_rule_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_contents_get_matching_rule_iter) + + The returned pointer must be deleted with + `pactffi_matching_rules_iter_delete` when done with it. + + Note that there could be multiple matching rules for the same key, so this + iterator will sequentially return each rule with the same key. + + For sample, given the following rules: + + ``` + "$.a" => Type, + "$.b" => Regex("\\d+"), Number + ``` + + This iterator will return a sequence of 3 values: + + - `("$.a", Type)` + - `("$.b", Regex("\d+"))` + - `("$.b", Number)` + + # Safety + + The iterator contains a copy of the data, so is safe to use when the message + or message contents has been deleted. + + # Error Handling + + On failure, this function will return a NULL pointer. + """ + return MatchingRuleCategoryIterator( + lib.pactffi_message_contents_get_matching_rule_iter(contents._ptr, category) + ) + + +def request_contents_get_matching_rule_iter( + request: HttpRequest, + category: MatchingRuleCategory, +) -> MatchingRuleCategoryIterator: + r""" + Get an iterator over the matching rules for a category of an HTTP request. + + [Rust `pactffi_request_contents_get_matching_rule_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_request_contents_get_matching_rule_iter) + + The returned pointer must be deleted with + `pactffi_matching_rules_iter_delete` when done with it. + + For sample, given the following rules: + + ``` + "$.a" => Type, + "$.b" => Regex("\d+"), Number + ``` + + This iterator will return a sequence of 3 values: + + - `("$.a", Type)` + - `("$.b", Regex("\d+"))` + - `("$.b", Number)` + + # Safety + + The iterator contains a copy of the data, so is safe to use when the + interaction or request contents has been deleted. + + # Error Handling + + On failure, this function will return a NULL pointer. + """ + raise NotImplementedError + + +def response_contents_get_matching_rule_iter( + response: HttpResponse, + category: MatchingRuleCategory, +) -> MatchingRuleCategoryIterator: + r""" + Get an iterator over the matching rules for a category of an HTTP response. + + [Rust `pactffi_response_contents_get_matching_rule_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_response_contents_get_matching_rule_iter) + + The returned pointer must be deleted with + `pactffi_matching_rules_iter_delete` when done with it. + + For sample, given the following rules: + + ``` + "$.a" => Type, + "$.b" => Regex("\d+"), Number + ``` + + This iterator will return a sequence of 3 values: + + - `("$.a", Type)` + - `("$.b", Regex("\d+"))` + - `("$.b", Number)` + + # Safety + + The iterator contains a copy of the data, so is safe to use when the + interaction or response contents has been deleted. + + # Error Handling + + On failure, this function will return a NULL pointer. + """ + raise NotImplementedError + + +def message_contents_get_generators_iter( + contents: MessageContents, + category: GeneratorCategory, +) -> GeneratorCategoryIterator: + """ + Get an iterator over the generators for a category of a message. + + [Rust + `pactffi_message_contents_get_generators_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_contents_get_generators_iter) + + # Safety + + The iterator contains a copy of the data, so is safe to use when the message + or message contents has been deleted. + + Raises: + RuntimeError: + If the generators iterator cannot be retrieved. + """ + ptr = lib.pactffi_message_contents_get_generators_iter(contents._ptr, category) + if ptr == ffi.NULL: + msg = "Unable to get the generators iterator from the message contents." + raise RuntimeError(msg) + return GeneratorCategoryIterator(ptr) + + +def request_contents_get_generators_iter( + request: HttpRequest, + category: GeneratorCategory, +) -> GeneratorCategoryIterator: + """ + Get an iterator over the generators for a category of an HTTP request. + + [Rust + `pactffi_request_contents_get_generators_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_request_contents_get_generators_iter) + + The returned pointer must be deleted with `pactffi_generators_iter_delete` + when done with it. + + # Safety + + The iterator contains a copy of the data, so is safe to use when the + interaction or request contents has been deleted. + + # Error Handling + + On failure, this function will return a NULL pointer. + """ + raise NotImplementedError + + +def response_contents_get_generators_iter( + response: HttpResponse, + category: GeneratorCategory, +) -> GeneratorCategoryIterator: + """ + Get an iterator over the generators for a category of an HTTP response. + + [Rust + `pactffi_response_contents_get_generators_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_response_contents_get_generators_iter) + + The returned pointer must be deleted with `pactffi_generators_iter_delete` + when done with it. + + # Safety + + The iterator contains a copy of the data, so is safe to use when the + interaction or response contents has been deleted. + + # Error Handling + + On failure, this function will return a NULL pointer. + """ + raise NotImplementedError + + +def parse_matcher_definition(expression: str) -> MatchingRuleDefinitionResult: + """ + Parse a matcher definition string into a MatchingRuleDefinition. + + The MatchingRuleDefinition contains the example value, and matching rules and + any generator. + + [Rust + `pactffi_parse_matcher_definition`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_parse_matcher_definition) + + The following are examples of matching rule definitions: + + * `matching(type,'Name')` - type matcher with string value 'Name' + * `matching(number,100)` - number matcher + * `matching(datetime, 'yyyy-MM-dd','2000-01-01')` - datetime matcher with + format string + + See [Matching Rule definition + expressions](https://docs.rs/pact_models/latest/pact_models/matchingrules/expressions/index.html). + + The returned value needs to be freed up with the + `pactffi_matcher_definition_delete` function. + + # Errors If the expression is invalid, the MatchingRuleDefinition error will + be set. You can check for this value with the + `pactffi_matcher_definition_error` function. + + # Safety + + This function is safe if the expression is a valid NULL terminated string + pointer. + """ + raise NotImplementedError + + +def matcher_definition_error(definition: MatchingRuleDefinitionResult) -> str: + """ + Returns any error message from parsing a matching definition expression. + + If there is no error, it will return a NULL pointer, otherwise returns the + error message as a NULL-terminated string. The returned string must be freed + using the `pactffi_string_delete` function once done with it. + + [Rust + `pactffi_matcher_definition_error`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matcher_definition_error) + """ + raise NotImplementedError + + +def matcher_definition_value(definition: MatchingRuleDefinitionResult) -> str: + """ + Returns the value from parsing a matching definition expression. + + If there was an error, it will return a NULL pointer, otherwise returns the + value as a NULL-terminated string. The returned string must be freed using + the `pactffi_string_delete` function once done with it. + + [Rust + `pactffi_matcher_definition_value`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matcher_definition_value) + + Note that different expressions values can have types other than a string. + Use `pactffi_matcher_definition_value_type` to get the actual type of the + value. This function will always return the string representation of the + value. + """ + raise NotImplementedError + + +def matcher_definition_delete(definition: MatchingRuleDefinitionResult) -> None: + """ + Frees the memory used by the result of parsing the matching definition expression. + + [Rust `pactffi_matcher_definition_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matcher_definition_delete) + """ + raise NotImplementedError + + +def matcher_definition_generator(definition: MatchingRuleDefinitionResult) -> Generator: + """ + Returns the generator from parsing a matching definition expression. + + If there was an error or there is no associated generator, it will return a + NULL pointer, otherwise returns the generator as a pointer. + + [Rust + `pactffi_matcher_definition_generator`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matcher_definition_generator) + + The generator pointer will be a valid pointer as long as + `pactffi_matcher_definition_delete` has not been called on the definition. + Using the generator pointer after the definition has been deleted will + result in undefined behaviour. + """ + raise NotImplementedError + + +def matcher_definition_value_type( + definition: MatchingRuleDefinitionResult, +) -> ExpressionValueType: + """ + Returns the type of the value from parsing a matching definition expression. + + If there was an error parsing the expression, it will return Unknown. + + [Rust + `pactffi_matcher_definition_value_type`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matcher_definition_value_type) + """ + raise NotImplementedError + + +def matching_rule_iter_delete(iter: MatchingRuleIterator) -> None: + """ + Free the iterator when you're done using it. + + [Rust `pactffi_matching_rule_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matching_rule_iter_delete) + """ + raise NotImplementedError + + +def matcher_definition_iter( + definition: MatchingRuleDefinitionResult, +) -> MatchingRuleIterator: + """ + Returns an iterator over the matching rules from the parsed definition. + + The iterator needs to be deleted with the + `pactffi_matching_rule_iter_delete` function once done with it. + + [Rust + `pactffi_matcher_definition_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matcher_definition_iter) + + If there was an error parsing the expression, this function will return a + NULL pointer. + """ + raise NotImplementedError + + +def matching_rule_iter_next(iter: MatchingRuleIterator) -> MatchingRuleResult: + """ + Get the next matching rule or reference from the iterator. + + As the values returned are owned by the iterator, they do not need to be + deleted but will be cleaned up when the iterator is deleted. + + [Rust + `pactffi_matching_rule_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matching_rule_iter_next) + + Will return a NULL pointer when the iterator has advanced past the end of + the list. + + # Safety + + This function is safe. + + # Error Handling + + This function will return a NULL pointer if passed a NULL pointer or if an + error occurs. + """ + raise NotImplementedError + + +def matching_rule_id(rule_result: MatchingRuleResult) -> int: + """ + Return the ID of the matching rule. + + [Rust + `pactffi_matching_rule_id`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matching_rule_id) + + The ID corresponds to the following rules: + + | Rule | ID | + | ---- | -- | + | Equality | 1 | + | Regex | 2 | + | Type | 3 | + | MinType | 4 | + | MaxType | 5 | + | MinMaxType | 6 | + | Timestamp | 7 | + | Time | 8 | + | Date | 9 | + | Include | 10 | + | Number | 11 | + | Integer | 12 | + | Decimal | 13 | + | Null | 14 | + | ContentType | 15 | + | ArrayContains | 16 | + | Values | 17 | + | Boolean | 18 | + | StatusCode | 19 | + | NotEmpty | 20 | + | Semver | 21 | + | EachKey | 22 | + | EachValue | 23 | + + # Safety + + This function is safe as long as the MatchingRuleResult pointer is a valid + pointer and the iterator has not been deleted. + """ + raise NotImplementedError + + +def matching_rule_value(rule_result: MatchingRuleResult) -> str: + """ + Returns the associated value for the matching rule. + + If the matching rule does not have an associated value, will return a NULL + pointer. + + [Rust + `pactffi_matching_rule_value`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matching_rule_value) + + The associated values for the rules are: + + | Rule | ID | VALUE | + | ---- | -- | ----- | + | Equality | 1 | NULL | + | Regex | 2 | Regex value | + | Type | 3 | NULL | + | MinType | 4 | Minimum value | + | MaxType | 5 | Maximum value | + | MinMaxType | 6 | "min:max" | + | Timestamp | 7 | Format string | + | Time | 8 | Format string | + | Date | 9 | Format string | + | Include | 10 | String value | + | Number | 11 | NULL | + | Integer | 12 | NULL | + | Decimal | 13 | NULL | + | Null | 14 | NULL | + | ContentType | 15 | Content type | + | ArrayContains | 16 | NULL | + | Values | 17 | NULL | + | Boolean | 18 | NULL | + | StatusCode | 19 | NULL | + | NotEmpty | 20 | NULL | + | Semver | 21 | NULL | + | EachKey | 22 | NULL | + | EachValue | 23 | NULL | + + Will return a NULL pointer if the matching rule was a reference or does not + have an associated value. + + # Safety + + This function is safe as long as the MatchingRuleResult pointer is a valid + pointer and the iterator it came from has not been deleted. + """ + raise NotImplementedError + + +def matching_rule_pointer(rule_result: MatchingRuleResult) -> MatchingRule: + """ + Returns the matching rule pointer for the matching rule. + + Will return a NULL pointer if the matching rule result was a reference. + + [Rust + `pactffi_matching_rule_pointer`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matching_rule_pointer) + + # Safety + + This function is safe as long as the MatchingRuleResult pointer is a valid + pointer and the iterator it came from has not been deleted. + """ + raise NotImplementedError + + +def matching_rule_reference_name(rule_result: MatchingRuleResult) -> str: + """ + Return any matching rule reference to a attribute by name. + + This is when the matcher should be configured to match the type of a + structure. I.e., + + [Rust + `pactffi_matching_rule_reference_name`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matching_rule_reference_name) + + ```json + { + "pact:match": "eachValue(matching($'person'))", + "person": { + "name": "Fred", + "age": 100 + } + } + ``` + + Will return a NULL pointer if the matching rule was not a reference. + + # Safety + + This function is safe as long as the MatchingRuleResult pointer is a valid + pointer and the iterator has not been deleted. + """ + raise NotImplementedError + + +def validate_datetime(value: str, format: str) -> None: + """ + Validates the date/time value against the date/time format string. + + [Rust + `pactffi_validate_datetime`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_validate_datetime) + + Raises: + ValueError: + If the value is not a valid date/time for the format string. + + RuntimeError: + For any other error. + """ + ret = lib.pactffi_validate_datetime(value.encode(), format.encode()) + if ret == 0: + return + if ret == 1: + msg = f"Invalid datetime value {value!r}' for format {format!r}" + raise ValueError(msg) + if ret == 2: # noqa: PLR2004 + msg = f"Panic while validating datetime value: {get_error_message()}" + else: + msg = f"Unknown error while validating datetime value: {ret}" + raise RuntimeError(msg) + + +def generator_to_json(generator: Generator) -> str: + """ + Get the JSON form of the generator. + + [Rust + `pactffi_generator_to_json`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_generator_to_json) + + The returned string must be deleted with `pactffi_string_delete`. + + # Safety + + This function will fail if it is passed a NULL pointer, or the owner of the + generator has been deleted. + """ + return OwnedString(lib.pactffi_generator_to_json(generator._ptr)) + + +def generator_generate_string(generator: Generator, context_json: str) -> str: + """ + Generate a string value using the provided generator. + + An optional JSON payload containing any generator context ca be given. The + context value is used for generators like `MockServerURL` (which should + contain details about the running mock server) and `ProviderStateGenerator` + (which should be the values returned from the Provider State callback + function). + + [Rust + `pactffi_generator_generate_string`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_generator_generate_string) + + If anything goes wrong, it will return a NULL pointer. + """ + ptr = lib.pactffi_generator_generate_string( + generator._ptr, + context_json.encode("utf-8"), + ) + s = ffi.string(ptr) + if isinstance(s, bytes): + s = s.decode("utf-8") + return s + + +def generator_generate_integer(generator: Generator, context_json: str) -> int: + """ + Generate an integer value using the provided generator. + + An optional JSON payload containing any generator context can be given. The + context value is used for generators like `ProviderStateGenerator` (which + should be the values returned from the Provider State callback function). + + [Rust + `pactffi_generator_generate_integer`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_generator_generate_integer) + + If anything goes wrong or the generator is not a type that can generate an + integer value, it will return a zero value. + """ + return lib.pactffi_generator_generate_integer( + generator._ptr, + context_json.encode("utf-8"), + ) + + +def generators_iter_delete(iter: GeneratorCategoryIterator) -> None: + """ + Free the iterator when you're done using it. + + [Rust + `pactffi_generators_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_generators_iter_delete) + """ + lib.pactffi_generators_iter_delete(iter._ptr) + + +def generators_iter_next(iter: GeneratorCategoryIterator) -> GeneratorKeyValuePair: + """ + Get the next path and generator out of the iterator, if possible. + + [Rust + `pactffi_generators_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_generators_iter_next) + + The returned pointer must be deleted with + `pactffi_generator_iter_pair_delete`. + + Raises: + StopIteration: + If the iterator has reached the end. + """ + ptr = lib.pactffi_generators_iter_next(iter._ptr) + if ptr == ffi.NULL: + raise StopIteration + return GeneratorKeyValuePair(ptr) + + +def generators_iter_pair_delete(pair: GeneratorKeyValuePair) -> None: + """ + Free a pair of key and value returned from `pactffi_generators_iter_next`. + + [Rust + `pactffi_generators_iter_pair_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_generators_iter_pair_delete) + """ + lib.pactffi_generators_iter_pair_delete(pair._ptr) + + +def sync_http_new() -> SynchronousHttp: + """ + Get a mutable pointer to a newly-created default interaction on the heap. + + [Rust `pactffi_sync_http_new`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_new) + + # Safety + + This function is safe. + + # Error Handling + + Returns NULL on error. + """ + raise NotImplementedError + + +def sync_http_delete(interaction: SynchronousHttp) -> None: + """ + Destroy the `SynchronousHttp` interaction being pointed to. + + [Rust + `pactffi_sync_http_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_delete) + """ + lib.pactffi_sync_http_delete(interaction._ptr) + + +def sync_http_get_request(interaction: SynchronousHttp) -> HttpRequest: + """ + Get the request of a `SynchronousHttp` interaction. + + [Rust + `pactffi_sync_http_get_request`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_get_request) + + # Safety + + The data pointed to by the pointer this function returns will be deleted + when the interaction is deleted. Trying to use if after the interaction is + deleted will result in undefined behaviour. + + # Error Handling + + If the interaction is NULL, returns NULL. + """ + raise NotImplementedError + + +def sync_http_get_request_contents(interaction: SynchronousHttp) -> str | None: + """ + Get the request contents of a `SynchronousHttp` interaction in string form. + + [Rust + `pactffi_sync_http_get_request_contents`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_get_request_contents) + + Note that this function will return `None` if either the body is missing or + is `null`. + """ + ptr = lib.pactffi_sync_http_get_request_contents(interaction._ptr) + if ptr == ffi.NULL: + return None + return OwnedString(ptr) + + +def sync_http_set_request_contents( + interaction: SynchronousHttp, + contents: str, + content_type: str, +) -> None: + """ + Sets the request contents of the interaction. + + [Rust + `pactffi_sync_http_set_request_contents`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_set_request_contents) + + - `interaction` - the interaction to set the request contents for + - `contents` - pointer to contents to copy from. Must be a valid + NULL-terminated UTF-8 string pointer. + - `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The request contents and content type must either be NULL pointers, or point + to valid UTF-8 encoded NULL-terminated strings. Otherwise behaviour is + undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the request contents as null. + If the content type is a null pointer, or can't be parsed, it will set the + content type as unknown. + """ + raise NotImplementedError + + +def sync_http_get_request_contents_length(interaction: SynchronousHttp) -> int: + """ + Get the length of the request contents of a `SynchronousHttp` interaction. + + [Rust + `pactffi_sync_http_get_request_contents_length`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_get_request_contents_length) + + This function will return 0 if the body is missing. + """ + return lib.pactffi_sync_http_get_request_contents_length(interaction._ptr) + + +def sync_http_get_request_contents_bin(interaction: SynchronousHttp) -> bytes | None: + """ + Get the request contents of a `SynchronousHttp` interaction as bytes. + + [Rust + `pactffi_sync_http_get_request_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_get_request_contents_bin) + + Note that this function will return `None` if either the body is missing or + is `null`. + """ + ptr = lib.pactffi_sync_http_get_request_contents_bin(interaction._ptr) + if ptr == ffi.NULL: + return None + return ffi.buffer( + ptr, + sync_http_get_request_contents_length(interaction), + )[:] + + +def sync_http_set_request_contents_bin( + interaction: SynchronousHttp, + contents: str, + len: int, + content_type: str, +) -> None: + """ + Sets the request contents of the interaction as an array of bytes. + + [Rust + `pactffi_sync_http_set_request_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_set_request_contents_bin) + + - `interaction` - the interaction to set the request contents for + - `contents` - pointer to contents to copy from + - `len` - number of bytes to copy from the contents pointer + - `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The contents pointer must be valid for reads of `len` bytes, and it must be + properly aligned and consecutive. Otherwise behaviour is undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the request contents as null. + If the content type is a null pointer, or can't be parsed, it will set the + content type as unknown. + """ + raise NotImplementedError + + +def sync_http_get_response(interaction: SynchronousHttp) -> HttpResponse: + """ + Get the response of a `SynchronousHttp` interaction. + + [Rust + `pactffi_sync_http_get_response`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_get_response) + + # Safety + + The data pointed to by the pointer this function returns will be deleted + when the interaction is deleted. Trying to use if after the interaction is + deleted will result in undefined behaviour. + + # Error Handling + + If the interaction is NULL, returns NULL. + """ + raise NotImplementedError + + +def sync_http_get_response_contents(interaction: SynchronousHttp) -> str | None: + """ + Get the response contents of a `SynchronousHttp` interaction in string form. + + [Rust + `pactffi_sync_http_get_response_contents`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_get_response_contents) + + Note that this function will return `None` if either the body is missing or + is `null`. + """ + ptr = lib.pactffi_sync_http_get_response_contents(interaction._ptr) + if ptr == ffi.NULL: + return None + return OwnedString(ptr) + + +def sync_http_set_response_contents( + interaction: SynchronousHttp, + contents: str, + content_type: str, +) -> None: + """ + Sets the response contents of the interaction. + + [Rust + `pactffi_sync_http_set_response_contents`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_set_response_contents) + + - `interaction` - the interaction to set the response contents for + - `contents` - pointer to contents to copy from. Must be a valid + NULL-terminated UTF-8 string pointer. + - `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The response contents and content type must either be NULL pointers, or + point to valid UTF-8 encoded NULL-terminated strings. Otherwise behaviour is + undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the response contents as + null. If the content type is a null pointer, or can't be parsed, it will set + the content type as unknown. + """ + raise NotImplementedError + + +def sync_http_get_response_contents_length(interaction: SynchronousHttp) -> int: + """ + Get the length of the response contents of a `SynchronousHttp` interaction. + + [Rust + `pactffi_sync_http_get_response_contents_length`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_get_response_contents_length) + + This function will return 0 if the body is missing. + """ + return lib.pactffi_sync_http_get_response_contents_length(interaction._ptr) + + +def sync_http_get_response_contents_bin(interaction: SynchronousHttp) -> bytes | None: + """ + Get the response contents of a `SynchronousHttp` interaction as bytes. + + [Rust + `pactffi_sync_http_get_response_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_get_response_contents_bin) + + Note that this function will return `None` if either the body is missing or + is `null`. + """ + ptr = lib.pactffi_sync_http_get_response_contents_bin(interaction._ptr) + if ptr == ffi.NULL: + return None + return ffi.buffer( + ptr, + sync_http_get_response_contents_length(interaction), + )[:] + + +def sync_http_set_response_contents_bin( + interaction: SynchronousHttp, + contents: str, + len: int, + content_type: str, +) -> None: + """ + Sets the response contents of the `SynchronousHttp` interaction as bytes. + + [Rust + `pactffi_sync_http_set_response_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_set_response_contents_bin) + + - `interaction` - the interaction to set the response contents for + - `contents` - pointer to contents to copy from + - `len` - number of bytes to copy + - `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The contents pointer must be valid for reads of `len` bytes, and it must be + properly aligned and consecutive. Otherwise behaviour is undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the response contents as + null. If the content type is a null pointer, or can't be parsed, it will set + the content type as unknown. + """ + raise NotImplementedError + + +def sync_http_get_description(interaction: SynchronousHttp) -> str: + r""" + Get a copy of the description. + + [Rust + `pactffi_sync_http_get_description`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_get_description) + + Raises: + RuntimeError: + If the description cannot be retrieved + """ + ptr = lib.pactffi_sync_http_get_description(interaction._ptr) + if ptr == ffi.NULL: + msg = "Failed to get description" + raise RuntimeError(msg) + return OwnedString(ptr) + + +def sync_http_set_description(interaction: SynchronousHttp, description: str) -> int: + """ + Write the `description` field on the `SynchronousHttp`. + + [Rust + `pactffi_sync_http_set_description`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_set_description) + + # Safety + + `description` must contain valid UTF-8. Invalid UTF-8 will be replaced with + U+FFFD REPLACEMENT CHARACTER. + + This function will only reallocate if the new string does not fit in the + existing buffer. + + # Error Handling + + Errors will be reported with a non-zero return value. + """ + raise NotImplementedError + + +def sync_http_get_provider_state( + interaction: SynchronousHttp, + index: int, +) -> ProviderState: + r""" + Get a copy of the provider state at the given index from this interaction. + + [Rust + `pactffi_sync_http_get_provider_state`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_get_provider_state) + + # Safety + + The returned structure must be deleted with `provider_state_delete`. + + Since it is a copy, the returned structure may safely outlive the + `SynchronousHttp`. + + # Error Handling + + On failure, this function will return a variant other than Success. + + This function may fail if the index requested is out of bounds, or if any of + the Rust strings contain embedded null ('\0') bytes. + """ + raise NotImplementedError + + +def sync_http_get_provider_state_iter( + interaction: SynchronousHttp, +) -> ProviderStateIterator: + """ + Get an iterator over provider states. + + [Rust + `pactffi_sync_http_get_provider_state_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_http_get_provider_state_iter) + + # Safety + + The underlying data must not change during iteration. + + Raises: + RuntimeError: + If the iterator cannot be retrieved + """ + ptr = lib.pactffi_sync_http_get_provider_state_iter(interaction._ptr) + if ptr == ffi.NULL: + msg = "Failed to get provider state iterator" + raise RuntimeError(msg) + return ProviderStateIterator(ptr) + + +def pact_interaction_as_synchronous_http( + interaction: PactInteraction, +) -> SynchronousHttp: + """ + Cast this interaction to a `SynchronousHttp` interaction. + + [Rust `pactffi_pact_interaction_as_synchronous_http`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_interaction_as_synchronous_http) + + Args: + interaction: + The interaction to cast. + + Returns: + The interaction cast as a `SynchronousHttp`. + + Raises: + TypeError: + If the interaction cannot be cast to a `SynchronousHttp` + interaction. + """ + ptr = lib.pactffi_pact_interaction_as_synchronous_http(interaction._ptr) + if ptr == ffi.NULL: + msg = "Interaction is not a SynchronousHttp interaction" + raise TypeError(msg) + return SynchronousHttp(ptr, owned=False) + + +def pact_interaction_as_asynchronous_message( + interaction: PactInteraction, +) -> AsynchronousMessage: + """ + Cast this interaction to an `AsynchronousMessage` interaction. + + Note that if the interaction is a V3 `Message`, it will be converted to a V4 + `AsynchronousMessage` before being returned. + + [Rust `pactffi_pact_interaction_as_asynchronous_message`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_interaction_as_asynchronous_message) + + Args: + interaction: + The interaction to cast. + + Returns: + The interaction cast as an `AsynchronousMessage`. + + Raises: + TypeError: + If the interaction cannot be cast to an `AsynchronousMessage` + interaction. + """ + ptr = lib.pactffi_pact_interaction_as_asynchronous_message(interaction._ptr) + if ptr == ffi.NULL: + msg = "Interaction is not an AsynchronousMessage interaction" + raise TypeError(msg) + return AsynchronousMessage(ptr, owned=False) + + +def pact_interaction_as_synchronous_message( + interaction: PactInteraction, +) -> SynchronousMessage: + """ + Cast this interaction to a `SynchronousMessage` interaction. + + [Rust `pactffi_pact_interaction_as_synchronous_message`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_interaction_as_synchronous_message) + + Args: + interaction: + The interaction to cast. + + Returns: + The interaction cast as a `SynchronousMessage`. + + Raises: + TypeError: + If the interaction cannot be cast to a `SynchronousMessage` + interaction. + """ + ptr = lib.pactffi_pact_interaction_as_synchronous_message(interaction._ptr) + if ptr == ffi.NULL: + msg = "Interaction is not a SynchronousMessage interaction" + raise TypeError(msg) + return SynchronousMessage(ptr, owned=False) + + +def pact_async_message_iter_next(iter: PactAsyncMessageIterator) -> AsynchronousMessage: + """ + Get the next asynchronous message from the iterator. + + [Rust + `pactffi_pact_async_message_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_async_message_iter_next) + + Raises: + StopIteration: + If the iterator has reached the end. + """ + ptr = lib.pactffi_pact_async_message_iter_next(iter._ptr) + if ptr == ffi.NULL: + raise StopIteration + return AsynchronousMessage(ptr, owned=True) + + +def pact_async_message_iter_delete(iter: PactAsyncMessageIterator) -> None: + """ + Free the iterator when you're done using it. + + [Rust + `pactffi_pact_async_message_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_async_message_iter_delete) + """ + lib.pactffi_pact_async_message_iter_delete(iter._ptr) + + +def pact_sync_message_iter_next(iter: PactSyncMessageIterator) -> SynchronousMessage: + """ + Get the next synchronous request/response message from the V4 pact. + + [Rust + `pactffi_pact_sync_message_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_sync_message_iter_next) + + Raises: + StopIteration: + If the iterator has reached the end. + """ + ptr = lib.pactffi_pact_sync_message_iter_next(iter._ptr) + if ptr == ffi.NULL: + raise StopIteration + return SynchronousMessage(ptr, owned=True) + + +def pact_sync_message_iter_delete(iter: PactSyncMessageIterator) -> None: + """ + Free the iterator when you're done using it. + + [Rust + `pactffi_pact_sync_message_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_sync_message_iter_delete) + """ + lib.pactffi_pact_sync_message_iter_delete(iter._ptr) + + +def pact_sync_http_iter_next(iter: PactSyncHttpIterator) -> SynchronousHttp: + """ + Get the next synchronous HTTP request/response interaction from the V4 pact. + + [Rust + `pactffi_pact_sync_http_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_sync_http_iter_next) + + Raises: + StopIteration: + If the iterator has reached the end. + """ + ptr = lib.pactffi_pact_sync_http_iter_next(iter._ptr) + if ptr == ffi.NULL: + raise StopIteration + return SynchronousHttp(ptr, owned=True) + + +def pact_sync_http_iter_delete(iter: PactSyncHttpIterator) -> None: + """ + Free the iterator when you're done using it. + + [Rust + `pactffi_pact_sync_http_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_sync_http_iter_delete) + """ + lib.pactffi_pact_sync_http_iter_delete(iter._ptr) + + +def pact_interaction_iter_next(iter: PactInteractionIterator) -> PactInteraction: + """ + Get the next interaction from the pact. + + [Rust + `pactffi_pact_interaction_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_interaction_iter_next) + + Raises: + StopIteration: + If the iterator has reached the end. + """ + ptr = lib.pactffi_pact_interaction_iter_next(iter._ptr) + if ptr == ffi.NULL: + raise StopIteration + return PactInteraction(ptr, owned=True) + + +def pact_interaction_iter_delete(iter: PactInteractionIterator) -> None: + """ + Free the iterator when you're done using it. + + [Rust + `pactffi_pact_interaction_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_interaction_iter_delete) + """ + lib.pactffi_pact_interaction_iter_delete(iter._ptr) + + +def pact_message_iter_next(iter: PactMessageIterator) -> PactInteraction: + """ + Get the next interaction from the pact. + + [Rust + `pactffi_pact_message_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_message_iter_next) + + Raises: + StopIteration: + If the iterator has reached the end. + """ + ptr = lib.pactffi_pact_message_iter_next(iter._ptr) + if ptr == ffi.NULL: + raise StopIteration + return PactInteraction(ptr, owned=True) + + +def pact_message_iter_delete(iter: PactMessageIterator) -> None: + """ + Free the iterator when you're done using it. + + [Rust + `pactffi_pact_message_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_message_iter_delete) + """ + lib.pactffi_pact_message_iter_delete(iter._ptr) + + +def matching_rule_to_json(rule: MatchingRule) -> str: + """ + Get the JSON form of the matching rule. + + [Rust + `pactffi_matching_rule_to_json`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matching_rule_to_json) + + The returned string must be deleted with `pactffi_string_delete`. + + # Safety + + This function will fail if it is passed a NULL pointer, or the iterator that + owns the value of the matching rule has been deleted. + """ + return OwnedString(lib.pactffi_matching_rule_to_json(rule._ptr)) + + +def matching_rules_iter_delete(iter: MatchingRuleCategoryIterator) -> None: + """ + Free the iterator when you're done using it. + + [Rust + `pactffi_matching_rules_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matching_rules_iter_delete) + """ + lib.pactffi_matching_rules_iter_delete(iter._ptr) + + +def matching_rules_iter_next( + iter: MatchingRuleCategoryIterator, +) -> MatchingRuleKeyValuePair: + """ + Get the next path and matching rule out of the iterator, if possible. + + [Rust + `pactffi_matching_rules_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matching_rules_iter_next) + + The returned pointer must be deleted with + `pactffi_matching_rules_iter_pair_delete`. + + # Safety + + The underlying data is owned by the `MatchingRuleKeyValuePair`, so is always + safe to use. + + # Error Handling + + If no further data is present, returns NULL. + """ + return MatchingRuleKeyValuePair(lib.pactffi_matching_rules_iter_next(iter._ptr)) + + +def matching_rules_iter_pair_delete(pair: MatchingRuleKeyValuePair) -> None: + """ + Free a pair of key and value returned from `message_metadata_iter_next`. + + [Rust + `pactffi_matching_rules_iter_pair_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matching_rules_iter_pair_delete) + """ + lib.pactffi_matching_rules_iter_pair_delete(pair._ptr) + + +def provider_state_iter_next(iter: ProviderStateIterator) -> ProviderState: + """ + Get the next value from the iterator. + + [Rust + `pactffi_provider_state_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_provider_state_iter_next) + + # Safety + + The underlying data must not change during iteration. + + Raises: + StopIteration: + If no further data is present, or if an internal error occurs. + """ + provider_state = lib.pactffi_provider_state_iter_next(iter._ptr) + if provider_state == ffi.NULL: + raise StopIteration + return ProviderState(provider_state) + + +def provider_state_iter_delete(iter: ProviderStateIterator) -> None: + """ + Delete the iterator. + + [Rust + `pactffi_provider_state_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_provider_state_iter_delete) + """ + lib.pactffi_provider_state_iter_delete(iter._ptr) + + +def message_metadata_iter_next(iter: MessageMetadataIterator) -> MessageMetadataPair: + """ + Get the next key and value out of the iterator, if possible. + + [Rust + `pactffi_message_metadata_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_metadata_iter_next) + + The returned pointer must be deleted with + `pactffi_message_metadata_pair_delete`. + + # Safety + + The underlying data must not change during iteration. This function must + only ever be called from a foreign language. Calling it from a Rust function + that has a Tokio runtime in its call stack can result in a deadlock. + + Raises: + StopIteration: + If no further data is present. + """ + ptr = lib.pactffi_message_metadata_iter_next(iter._ptr) + if ptr == ffi.NULL: + raise StopIteration + return MessageMetadataPair(ptr) + + +def message_metadata_iter_delete(iter: MessageMetadataIterator) -> None: + """ + Free the metadata iterator when you're done using it. + + [Rust + `pactffi_message_metadata_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_metadata_iter_delete) + """ + lib.pactffi_message_metadata_iter_delete(iter._ptr) + + +def message_metadata_pair_delete(pair: MessageMetadataPair) -> None: + """ + Free a pair of key and value returned from `message_metadata_iter_next`. + + [Rust + `pactffi_message_metadata_pair_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_message_metadata_pair_delete) + """ + lib.pactffi_message_metadata_pair_delete(pair._ptr) + + +def provider_get_name(provider: Provider) -> str: + r""" + Get a copy of this provider's name. + + [Rust + `pactffi_provider_get_name`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_provider_get_name) + + The copy must be deleted with `pactffi_string_delete`. + + # Usage + + ```c + // Assuming `file_name` and `json_str` are already defined. + + MessagePact *message_pact = pactffi_message_pact_new_from_json(file_name, json_str); + if (message_pact == NULLPTR) { + // handle error. + } + + Provider *provider = pactffi_message_pact_get_provider(message_pact); + if (provider == NULLPTR) { + // handle error. + } + + char *name = pactffi_provider_get_name(provider); + if (name == NULL) { + // handle error. + } + + printf("%s\n", name); + + pactffi_string_delete(name); + ``` + + # Errors + + This function will fail if it is passed a NULL pointer, or the Rust string + contains an embedded NULL byte. In the case of error, a NULL pointer will be + returned. + """ + raise NotImplementedError + + +def pact_get_provider(pact: Pact) -> Provider: + """ + Get the provider from a Pact. + + This returns a copy of the provider model, and needs to be cleaned up with + `pactffi_pact_provider_delete` when no longer required. + + [Rust + `pactffi_pact_get_provider`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_get_provider) + + # Errors + + This function will fail if it is passed a NULL pointer. In the case of + error, a NULL pointer will be returned. + """ + raise NotImplementedError + + +def pact_provider_delete(provider: Provider) -> None: + """ + Frees the memory used by the Pact provider. + + [Rust + `pactffi_pact_provider_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_provider_delete) + """ + raise NotImplementedError + + +def provider_state_get_name(provider_state: ProviderState) -> str | None: + """ + Get the name of the provider state as a string. + + [Rust + `pactffi_provider_state_get_name`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_provider_state_get_name) + + Raises: + RuntimeError: + If the name could not be retrieved. + """ + ptr = lib.pactffi_provider_state_get_name(provider_state._ptr) + if ptr == ffi.NULL: + msg = "Failed to get provider state name." + raise RuntimeError(msg) + return OwnedString(ptr) + + +def provider_state_get_param_iter( + provider_state: ProviderState, +) -> ProviderStateParamIterator: + r""" + Get an iterator over the params of a provider state. + + [Rust + `pactffi_provider_state_get_param_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_provider_state_get_param_iter) + + # Safety + + This iterator carries a pointer to the provider state, and must not outlive + the provider state. + + The provider state params also must not be modified during iteration. If it + is, the old iterator must be deleted and a new iterator created. + + Raises: + RuntimeError: + If the iterator could not be created. + """ + ptr = lib.pactffi_provider_state_get_param_iter(provider_state._ptr) + if ptr == ffi.NULL: + msg = "Failed to get provider state param iterator." + raise RuntimeError(msg) + return ProviderStateParamIterator(ptr) + + +def provider_state_param_iter_next( + iter: ProviderStateParamIterator, +) -> ProviderStateParamPair: + """ + Get the next key and value out of the iterator, if possible. + + [Rust + `pactffi_provider_state_param_iter_next`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_provider_state_param_iter_next) + + # Safety + + The underlying data must not be modified during iteration. + + Raises: + StopIteration: + If no further data is present. + """ + provider_state_param = lib.pactffi_provider_state_param_iter_next(iter._ptr) + if provider_state_param == ffi.NULL: + raise StopIteration + return ProviderStateParamPair(provider_state_param) + + +def provider_state_delete(provider_state: ProviderState) -> None: + """ + Free the provider state when you're done using it. + + [Rust + `pactffi_provider_state_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_provider_state_delete) + """ + raise NotImplementedError + + +def provider_state_param_iter_delete(iter: ProviderStateParamIterator) -> None: + """ + Free the provider state param iterator when you're done using it. + + [Rust + `pactffi_provider_state_param_iter_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_provider_state_param_iter_delete) + """ + lib.pactffi_provider_state_param_iter_delete(iter._ptr) + + +def provider_state_param_pair_delete(pair: ProviderStateParamPair) -> None: + """ + Free a pair of key and value. + + [Rust + `pactffi_provider_state_param_pair_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_provider_state_param_pair_delete) + """ + lib.pactffi_provider_state_param_pair_delete(pair._ptr) + + +def sync_message_new() -> SynchronousMessage: + """ + Get a mutable pointer to a newly-created default message on the heap. + + [Rust + `pactffi_sync_message_new`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_new) + + # Safety + + This function is safe. + + # Error Handling + + Returns NULL on error. + """ + raise NotImplementedError + + +def sync_message_delete(message: SynchronousMessage) -> None: + """ + Destroy the `Message` being pointed to. + + [Rust + `pactffi_sync_message_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_delete) + """ + lib.pactffi_sync_message_delete(message._ptr) + + +def sync_message_get_request_contents_str(message: SynchronousMessage) -> str: + """ + Get the request contents of a `SynchronousMessage` in string form. + + [Rust + `pactffi_sync_message_get_request_contents_str`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_request_contents_str) + + # Safety + + The returned string must be deleted with `pactffi_string_delete`. + + The returned string can outlive the message. + + # Error Handling + + If the message is NULL, returns NULL. If the body of the request message is + missing, then this function also returns NULL. This means there's no + mechanism to differentiate with this function call alone between a NULL + message and a missing message body. + """ + raise NotImplementedError + + +def sync_message_set_request_contents_str( + message: SynchronousMessage, + contents: str, + content_type: str, +) -> None: + """ + Sets the request contents of the message. + + [Rust + `pactffi_sync_message_set_request_contents_str`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_set_request_contents_str) + + - `message` - the message to set the request contents for + - `contents` - pointer to contents to copy from. Must be a valid + NULL-terminated UTF-8 string pointer. + - `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The message contents and content type must either be NULL pointers, or point + to valid UTF-8 encoded NULL-terminated strings. Otherwise behaviour is + undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the message contents as null. + If the content type is a null pointer, or can't be parsed, it will set the + content type as unknown. + """ + raise NotImplementedError + + +def sync_message_get_request_contents_length(message: SynchronousMessage) -> int: + """ + Get the length of the request contents of a `SynchronousMessage`. + + [Rust + `pactffi_sync_message_get_request_contents_length`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_request_contents_length) + + # Safety + + This function is safe. + + # Error Handling + + If the message is NULL, returns 0. If the body of the request is missing, + then this function also returns 0. + """ + raise NotImplementedError + + +def sync_message_get_request_contents_bin(message: SynchronousMessage) -> bytes: + """ + Get the request contents of a `SynchronousMessage` as a bytes. + + [Rust + `pactffi_sync_message_get_request_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_request_contents_bin) + + # Safety + + The number of bytes in the buffer will be returned by + `pactffi_sync_message_get_request_contents_length`. It is safe to use the + pointer while the message is not deleted or changed. Using the pointer after + the message is mutated or deleted may lead to undefined behaviour. + + # Error Handling + + If the message is NULL, returns NULL. If the body of the message is missing, + then this function also returns NULL. + """ + raise NotImplementedError + + +def sync_message_set_request_contents_bin( + message: SynchronousMessage, + contents: str, + len: int, + content_type: str, +) -> None: + """ + Sets the request contents of the message as an array of bytes. + + [Rust + `pactffi_sync_message_set_request_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_set_request_contents_bin) + + * `message` - the message to set the request contents for + * `contents` - pointer to contents to copy from + * `len` - number of bytes to copy from the contents pointer + * `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The contents pointer must be valid for reads of `len` bytes, and it must be + properly aligned and consecutive. Otherwise behaviour is undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the message contents as null. + If the content type is a null pointer, or can't be parsed, it will set the + content type as unknown. + """ + raise NotImplementedError + + +def sync_message_get_request_contents(message: SynchronousMessage) -> MessageContents: + """ + Get the request contents of an `SynchronousMessage` as a `MessageContents`. + + [Rust + `pactffi_sync_message_get_request_contents`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_request_contents) + + # Safety + + The data pointed to by the pointer this function returns will be deleted + when the message is deleted. Trying to use if after the message is deleted + will result in undefined behaviour. + + # Error Handling + + If the message is NULL, returns NULL. + """ + raise NotImplementedError + + +def sync_message_generate_request_contents( + message: SynchronousMessage, +) -> MessageContents: + """ + Get the request contents of an `SynchronousMessage` as a `MessageContents`. + + This function differs from `pactffi_sync_message_get_request_contents` in + that it will process the message contents for any generators or matchers + that are present in the message in order to generate the actual message + contents as would be received by the consumer. + + [Rust + `pactffi_sync_message_generate_request_contents`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_generate_request_contents) + + Raises: + RuntimeError: + If the request contents cannot be generated + """ + ptr = lib.pactffi_sync_message_generate_request_contents(message._ptr) + if ptr == ffi.NULL: + msg = "Failed to generate request contents" + raise RuntimeError(msg) + return MessageContents(ptr, owned=False) + + +def sync_message_get_number_responses(message: SynchronousMessage) -> int: + """ + Get the number of response messages in the `SynchronousMessage`. + + [Rust + `pactffi_sync_message_get_number_responses`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_number_responses) + + If the message is null, this function will return 0. + """ + return lib.pactffi_sync_message_get_number_responses(message._ptr) + + +def sync_message_get_response_contents_str( + message: SynchronousMessage, + index: int, +) -> str: + """ + Get the response contents of a `SynchronousMessage` in string form. + + [Rust + `pactffi_sync_message_get_response_contents_str`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_response_contents_str) + + # Safety + + The returned string must be deleted with `pactffi_string_delete`. + + The returned string can outlive the message. + + # Error Handling + + If the message is NULL or the index is not valid, returns NULL. + + If the body of the response message is missing, then this function also + returns NULL. This means there's no mechanism to differentiate with this + function call alone between a NULL message and a missing message body. + """ + raise NotImplementedError + + +def sync_message_set_response_contents_str( + message: SynchronousMessage, + index: int, + contents: str, + content_type: str, +) -> None: + """ + Sets the response contents of the message as a string. + + If index is greater + than the number of responses in the message, the responses will be padded + with default values. + + [Rust + `pactffi_sync_message_set_response_contents_str`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_set_response_contents_str) + + * `message` - the message to set the response contents for + * `index` - index of the response to set. 0 is the first response. + * `contents` - pointer to contents to copy from. Must be a valid + NULL-terminated UTF-8 string pointer. + * `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The message contents and content type must either be NULL pointers, or point + to valid UTF-8 encoded NULL-terminated strings. Otherwise behaviour is + undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the response contents as + null. If the content type is a null pointer, or can't be parsed, it will set + the content type as unknown. + """ + raise NotImplementedError + + +def sync_message_get_response_contents_length( + message: SynchronousMessage, + index: int, +) -> int: + """ + Get the length of the response contents of a `SynchronousMessage`. + + [Rust + `pactffi_sync_message_get_response_contents_length`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_response_contents_length) + + # Safety + + This function is safe. + + # Error Handling + + If the message is NULL or the index is not valid, returns 0. If the body of + the request is missing, then this function also returns 0. + """ + raise NotImplementedError + + +def sync_message_get_response_contents_bin( + message: SynchronousMessage, + index: int, +) -> bytes: + """ + Get the response contents of a `SynchronousMessage` as bytes. + + [Rust + `pactffi_sync_message_get_response_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_response_contents_bin) + + # Safety + + The number of bytes in the buffer will be returned by + `pactffi_sync_message_get_response_contents_length`. It is safe to use the + pointer while the message is not deleted or changed. Using the pointer after + the message is mutated or deleted may lead to undefined behaviour. + + # Error Handling + + If the message is NULL or the index is not valid, returns NULL. If the body + of the message is missing, then this function also returns NULL. + """ + raise NotImplementedError + + +def sync_message_set_response_contents_bin( + message: SynchronousMessage, + index: int, + contents: str, + len: int, + content_type: str, +) -> None: + """ + Sets the response contents of the message at the given index as bytes. + + If index is greater than the number of responses in the message, the + responses will be padded with default values. + + [Rust + `pactffi_sync_message_set_response_contents_bin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_set_response_contents_bin) + + * `message` - the message to set the response contents for + * `index` - index of the response to set. 0 is the first response + * `contents` - pointer to contents to copy from + * `len` - number of bytes to copy + * `content_type` - pointer to the NULL-terminated UTF-8 string containing + the content type of the data. + + # Safety + + The contents pointer must be valid for reads of `len` bytes, and it must be + properly aligned and consecutive. Otherwise behaviour is undefined. + + # Error Handling + + If the contents is a NULL pointer, it will set the message contents as null. + If the content type is a null pointer, or can't be parsed, it will set the + content type as unknown. + """ + raise NotImplementedError + + +def sync_message_get_response_contents( + message: SynchronousMessage, + index: int, +) -> MessageContents: + """ + Get the response contents of an `SynchronousMessage` as a `MessageContents`. + + [Rust + `pactffi_sync_message_get_response_contents`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_response_contents) + + # Safety + + The data pointed to by the pointer this function returns will be deleted + when the message is deleted. Trying to use if after the message is deleted + will result in undefined behaviour. + + # Error Handling + + If the message is NULL or the index is not valid, returns NULL. + """ + raise NotImplementedError + + +def sync_message_generate_response_contents( + message: SynchronousMessage, + index: int, +) -> MessageContents: + """ + Get the response contents of an `SynchronousMessage` as a `MessageContents`. + + This function differs from + `sync_message_get_response_contents` in that it will process + the message contents for any generators or matchers that are present in + the message in order to generate the actual message contents as would be + received by the consumer. + + [Rust + `pactffi_sync_message_generate_response_contents`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_generate_response_contents) + + Raises: + RuntimeError: + If the response contents could not be generated. + """ + ptr = lib.pactffi_sync_message_generate_response_contents(message._ptr, index) + if ptr == ffi.NULL: + msg = "Failed to generate response contents." + raise RuntimeError(msg) + return MessageContents(ptr, owned=False) + + +def sync_message_get_description(message: SynchronousMessage) -> str: + r""" + Get a copy of the description. + + [Rust + `pactffi_sync_message_get_description`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_description) + + Raises: + RuntimeError: + If the description could not be retrieved + """ + ptr = lib.pactffi_sync_message_get_description(message._ptr) + if ptr == ffi.NULL: + msg = "Failed to get description." + raise RuntimeError(msg) + return OwnedString(ptr) + + +def sync_message_set_description(message: SynchronousMessage, description: str) -> int: + """ + Write the `description` field on the `SynchronousMessage`. + + [Rust + `pactffi_sync_message_set_description`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_set_description) + + # Safety + + `description` must contain valid UTF-8. Invalid UTF-8 will be replaced with + U+FFFD REPLACEMENT CHARACTER. + + This function will only reallocate if the new string does not fit in the + existing buffer. + + # Error Handling + + Errors will be reported with a non-zero return value. + """ + raise NotImplementedError + + +def sync_message_get_provider_state( + message: SynchronousMessage, + index: int, +) -> ProviderState: + r""" + Get a copy of the provider state at the given index from this message. + + [Rust + `pactffi_sync_message_get_provider_state`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_provider_state) + + # Safety + + The returned structure must be deleted with `provider_state_delete`. + + Since it is a copy, the returned structure may safely outlive the + `SynchronousMessage`. + + # Error Handling + + On failure, this function will return a variant other than Success. + + This function may fail if the index requested is out of bounds, or if any of + the Rust strings contain embedded null ('\0') bytes. + """ + raise NotImplementedError + + +def sync_message_get_provider_state_iter( + message: SynchronousMessage, +) -> ProviderStateIterator: + """ + Get an iterator over provider states. + + [Rust + `pactffi_sync_message_get_provider_state_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_sync_message_get_provider_state_iter) + + # Safety + + The underlying data must not change during iteration. + + Raises: + RuntimeError: + If the iterator could not be created. + """ + ptr = lib.pactffi_sync_message_get_provider_state_iter(message._ptr) + if ptr == ffi.NULL: + msg = "Failed to get provider state iterator." + raise RuntimeError(msg) + return ProviderStateIterator(ptr) + + +def string_delete(string: OwnedString) -> None: + """ + Delete a string previously returned by this FFI. + + [Rust + `pactffi_string_delete`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_string_delete) + """ + lib.pactffi_string_delete(string._ptr) + + +def get_tls_ca_certificate() -> OwnedString: + """ + Fetch the CA Certificate used to generate the self-signed certificate. + + [Rust + `pactffi_get_tls_ca_certificate`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_get_tls_ca_certificate) + + **NOTE:** The string for the result is allocated on the heap, and will have + to be freed by the caller using [`string_delete`][pact_ffi.string_delete]. + + # Errors + + An empty string indicates an error reading the pem file. + """ + return OwnedString(lib.pactffi_get_tls_ca_certificate()) + + +def create_mock_server_for_transport( + pact: PactHandle, + addr: str, + port: int, + transport: str, + transport_config: str | None, +) -> PactServerHandle: + """ + Create a mock server for the provided Pact handle and transport. + + [Rust + `pactffi_create_mock_server_for_transport`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_create_mock_server_for_transport) + + Args: + pact: + Handle to the Pact model. + + addr: + The address to bind to. + + port: + The port number to bind to. A value of zero will result in the + operating system allocating an available port. + + transport: + The transport to use (i.e. http, https, grpc). The underlying Pact + library will interpret this, typically in a case-sensitive way. + + transport_config: + Configuration to be passed to the transport. This must be a valid + JSON string, or `None` if not required. + + Returns: + A handle to the mock server. + + Raises: + RuntimeError: + If the mock server could not be created. The error message will + contain details of the error. + """ + ret: int = lib.pactffi_create_mock_server_for_transport( + pact._ref, + addr.encode("utf-8"), + port, + transport.encode("utf-8"), + (transport_config.encode("utf-8") if transport_config else ffi.NULL), + ) + if ret > 0: + return PactServerHandle(ret) + + if ret == -1: + msg = f"An invalid Pact handle was received: {pact}." + elif ret == -2: # noqa: PLR2004 + msg = "Invalid transport_config JSON." + elif ret == -3: # noqa: PLR2004 + msg = f"Pact mock server could not be started for {pact}." + elif ret == -4: # noqa: PLR2004 + msg = f"Panick during Pact mock server creation for {pact}." + elif ret == -5: # noqa: PLR2004 + msg = f"Address is invalid: {addr}." + else: + msg = f"An unknown error occurred during Pact mock server creation for {pact}." + raise RuntimeError(msg) + + +def mock_server_matched(mock_server_handle: PactServerHandle) -> bool: + """ + External interface to check if a mock server has matched all its requests. + + If all requests have been matched, `true` is returned. `false` is returned + if any request has not been successfully matched, or the method panics. + + [Rust + `pactffi_mock_server_matched`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mock_server_matched) + """ + return lib.pactffi_mock_server_matched(mock_server_handle._ref) + + +def mock_server_mismatches( + mock_server_handle: PactServerHandle, +) -> list[dict[str, Any]]: + """ + External interface to get all the mismatches from a mock server. + + [Rust + `pactffi_mock_server_mismatches`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mock_server_mismatches) + + # Errors + + Raises: + RuntimeError: + If there is no mock server with the provided port number, or the + function panics. + """ + ptr = lib.pactffi_mock_server_mismatches(mock_server_handle._ref) + if ptr == ffi.NULL: + msg = f"No mock server found with port {mock_server_handle}." + raise RuntimeError(msg) + string = ffi.string(ptr) + if isinstance(string, bytes): + string = string.decode("utf-8") + return json.loads(string) + + +def cleanup_mock_server(mock_server_handle: PactServerHandle) -> None: + """ + External interface to cleanup a mock server. + + This function will try terminate the mock server with the given port number + and cleanup any memory allocated for it. + + [Rust + `pactffi_cleanup_mock_server`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_cleanup_mock_server) + + Args: + mock_server_handle: + Handle to the mock server to cleanup. + + Raises: + RuntimeError: + If the mock server could not be cleaned up. + """ + success: bool = lib.pactffi_cleanup_mock_server(mock_server_handle._ref) + if not success: + msg = f"Could not cleanup mock server with port {mock_server_handle._ref}" + raise RuntimeError(msg) + + +def write_pact_file( + mock_server_handle: PactServerHandle, + directory: str | Path, + *, + overwrite: bool, +) -> None: + """ + External interface to trigger a mock server to write out its pact file. + + This function should be called if all the consumer tests have passed. The + directory to write the file to is passed as the second parameter. + + [Rust + `pactffi_write_pact_file`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_write_pact_file) + + Args: + mock_server_handle: + Handle to the mock server to write the pact file for. + + directory: + Directory to write the pact file to. + + overwrite: + Whether to overwrite any existing pact files. If this is false, the + pact file will be merged with any existing pact file. + + Raises: + RuntimeError: + If there was an error writing the pact file. + """ + ret: int = lib.pactffi_write_pact_file( + mock_server_handle._ref, + str(directory).encode("utf-8"), + overwrite, + ) + if ret == 0: + return + if ret == 1: + msg = ( + f"The function panicked while writing the Pact for {mock_server_handle} in" + f" {directory}." + ) + elif ret == 2: # noqa: PLR2004 + msg = ( + f"The Pact file for {mock_server_handle} could not be written in" + f" {directory}." + ) + elif ret == 3: # noqa: PLR2004 + msg = f"The Pact for the {mock_server_handle} was not found." + else: + msg = ( + "An unknown error occurred while writing the Pact for" + f" {mock_server_handle} in {directory}." + ) + raise RuntimeError(msg) + + +def mock_server_logs(mock_server_handle: PactServerHandle) -> str: + """ + Fetch the logs for the mock server. + + This needs the memory buffer log sink to be setup before the mock server is + started. + + [Rust + `pactffi_mock_server_logs`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_mock_server_logs) + + Raises: + RuntimeError: + If the logs for the mock server can not be retrieved. + """ + ptr = lib.pactffi_mock_server_logs(mock_server_handle._ref) + if ptr == ffi.NULL: + msg = f"Unable to obtain logs from {mock_server_handle!r}" + raise RuntimeError(msg) + string = ffi.string(ptr) + if isinstance(string, bytes): + string = string.decode("utf-8") + return string + + +def generate_datetime_string(format: str) -> StringResult: + """ + Generates a datetime value from the provided format string. + + This uses the current system date and time NOTE: The memory for the returned + string needs to be freed with the `pactffi_string_delete` function + + [Rust + `pactffi_generate_datetime_string`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_generate_datetime_string) + + # Safety + + If the format string pointer is NULL or has invalid UTF-8 characters, an + error result will be returned. If the format string pointer is not a valid + pointer or is not a NULL-terminated string, this will lead to undefined + behaviour. + """ + raise NotImplementedError + + +def check_regex(regex: str, example: str) -> bool: + """ + Checks that the example string matches the given regex. + + [Rust + `pactffi_check_regex`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_check_regex) + + # Safety + + Both the regex and example pointers must be valid pointers to + NULL-terminated strings. Invalid pointers will result in undefined + behaviour. + """ + raise NotImplementedError + + +def generate_regex_value(regex: str) -> StringResult: + """ + Generates an example string based on the provided regex. + + NOTE: The memory for the returned string needs to be freed with the + `pactffi_string_delete` function. + + [Rust + `pactffi_generate_regex_value`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_generate_regex_value) + + # Safety + + The regex pointer must be a valid pointer to a NULL-terminated string. + Invalid pointers will result in undefined behaviour. + """ + raise NotImplementedError + + +def free_string(s: str) -> None: + """ + [DEPRECATED] Frees the memory allocated to a string by another function. + + [Rust + `pactffi_free_string`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_free_string) + + This function is deprecated. Use `pactffi_string_delete` instead. + + # Safety + + The string pointer can be NULL (which is a no-op), but if it is not a valid + pointer the call will result in undefined behaviour. + """ + warnings.warn( + "This function is deprecated, use string_delete instead", + DeprecationWarning, + stacklevel=2, + ) + raise NotImplementedError + + +def new_pact(consumer_name: str, provider_name: str) -> PactHandle: + """ + Creates a new Pact model and returns a handle to it. + + [Rust + `pactffi_new_pact`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_new_pact) + + Args: + consumer_name: + The name of the consumer for the pact. + + provider_name: + The name of the provider for the pact. + + Returns: + Handle to the new Pact model. + """ + return PactHandle( + lib.pactffi_new_pact( + consumer_name.encode("utf-8"), + provider_name.encode("utf-8"), + ), + ) + + +def pact_handle_to_pointer(pact: PactHandle) -> Pact: + """ + Copy a Pact handle to a raw Pact. + + The underlying data is cloned, therefore, any changes made to the original + Pact handle will not be reflected in the Pact model, and vice versa. + + Args: + pact: + The Pact handle to unwrap. + + Returns: + The underlying Pact model pointer. + + Raises: + RuntimeError: + If the unwrap operation fails. + """ + ptr = lib.pactffi_pact_handle_to_pointer(pact._ref) + if ptr == ffi.NULL: + msg = f"Failed to unwrap pact handle: {pact}" + raise RuntimeError(msg) + return Pact(ptr, owned=False) + + +def new_interaction(pact: PactHandle, description: str) -> InteractionHandle: + """ + Creates a new HTTP Interaction and returns a handle to it. + + Calling this function with the same description as an existing interaction + will result in that interaction being replaced with the new one. + + [Rust + `pactffi_new_interaction`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_new_interaction) + + Args: + pact: + Handle to the Pact model. + + description: + The interaction description. It needs to be unique for each Pact. + + Returns: + Handle to the new Interaction. + """ + return InteractionHandle( + lib.pactffi_new_interaction( + pact._ref, + description.encode("utf-8"), + ), + ) + + +def new_message_interaction(pact: PactHandle, description: str) -> InteractionHandle: + """ + Creates a new message interaction and returns a handle to it. + + Calling this function with the same description as an existing interaction + will result in that interaction being replaced with the new one. + + [Rust + `pactffi_new_message_interaction`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_new_message_interaction) + + Args: + pact: + Handle to the Pact model. + + description: + The interaction description. It needs to be unique for each Pact. + + Returns: + Handle to the new Interaction + """ + return InteractionHandle( + lib.pactffi_new_message_interaction( + pact._ref, + description.encode("utf-8"), + ), + ) + + +def new_sync_message_interaction( + pact: PactHandle, + description: str, +) -> InteractionHandle: + """ + Creates a new synchronous message interaction and returns a handle to it. + + Calling this function with the same description as an existing interaction + will result in that interaction being replaced with the new one. + + [Rust + `pactffi_new_sync_message_interaction`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_new_sync_message_interaction) + + Args: + pact: + Handle to the Pact model. + + description: + The interaction description. It needs to be unique for each Pact. + + Returns: + Handle to the new Interaction + """ + return InteractionHandle( + lib.pactffi_new_sync_message_interaction( + pact._ref, + description.encode("utf-8"), + ), + ) + + +def upon_receiving(interaction: InteractionHandle, description: str) -> None: + """ + Sets the description for the Interaction. + + [Rust + `pactffi_upon_receiving`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_upon_receiving) + + This function + + Args: + interaction: + Handle to the Interaction. + + description: + The interaction description. It needs to be unique for each Pact. + + Raises: + NotImplementedError: + This function has intentionally been left unimplemented. + + RuntimeError: + If the interaction description could not be set. + """ + # This function has intentionally been left unimplemented. The rationale is + # to avoid code of the form: + # + # ```python + # .with_request("GET", "/") + # .upon_receiving("some new description") + # ``` + raise NotImplementedError + + success: bool = lib.pactffi_upon_receiving( + interaction._ref, + description.encode("utf-8"), + ) + if not success: + msg = "The interaction description could not be set." + raise RuntimeError(msg) + + +def given(interaction: InteractionHandle, description: str) -> None: + """ + Adds a provider state to the Interaction. + + [Rust + `pactffi_given`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_given) + + Args: + interaction: + Handle to the Interaction. + + description: + The provider state description. It needs to be unique. + + Raises: + RuntimeError: + If the provider state could not be specified. + """ + success: bool = lib.pactffi_given(interaction._ref, description.encode("utf-8")) + if not success: + msg = "The provider state could not be specified." + raise RuntimeError(msg) + + +def interaction_test_name(interaction: InteractionHandle, test_name: str) -> None: + """ + Sets the test name annotation for the interaction. + + This allows capturing the name of the test as metadata. This can only be + used with V4 interactions. + + [Rust + `pactffi_interaction_test_name`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_interaction_test_name) + + Args: + interaction: + Handle to the Interaction. + + test_name: + The test name to set. + + Raises: + RuntimeError: + If the test name can not be set. + + """ + ret: int = lib.pactffi_interaction_test_name( + interaction._ref, + test_name.encode("utf-8"), + ) + if ret == 0: + return + if ret == 1: + msg = f"Function panicked: {get_error_message()}" + elif ret == 2: # noqa: PLR2004 + msg = f"Invalid handle: {interaction}." + elif ret == 3: # noqa: PLR2004 + msg = f"Mock server for {interaction} has already started." + elif ret == 4: # noqa: PLR2004 + msg = f"Interaction {interaction} is not a V4 interaction." + else: + msg = f"Unknown error setting test name for {interaction}." + raise RuntimeError(msg) + + +def given_with_param( + interaction: InteractionHandle, + description: str, + name: str, + value: str, +) -> None: + """ + Adds a parameter key and value to a provider state to the Interaction. + + If the provider state does not exist, a new one will be created, otherwise + the parameter will be merged into the existing one. The parameter value will + be parsed as JSON. + + [Rust + `pactffi_given_with_param`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_given_with_param) + + Args: + interaction: + Handle to the Interaction. + + description: + The provider state description. + + name: + Parameter name. + + value: + Parameter value as JSON. + + Raises: + RuntimeError: + If the interaction state could not be updated. + """ + success: bool = lib.pactffi_given_with_param( + interaction._ref, + description.encode("utf-8"), + name.encode("utf-8"), + value.encode("utf-8"), + ) + if not success: + msg = "The interaction state could not be updated." + raise RuntimeError(msg) + + +def given_with_params( + interaction: InteractionHandle, + description: str, + params: str, +) -> None: + """ + Adds a provider state to the Interaction. + + If the params is not an JSON object, it will add it as a single parameter + with a `value` key. + + [Rust + `pactffi_given_with_params`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_given_with_params) + + Args: + interaction: + Handle to the Interaction. + + description: + The provider state description. + + params: + Parameter values as a JSON fragment. + + Raises: + RuntimeError: + If the interaction state could not be updated. + """ + ret: int = lib.pactffi_given_with_params( + interaction._ref, + description.encode("utf-8"), + params.encode("utf-8"), + ) + if ret == 0: + return + if ret == 1: + msg = "The interaction state could not be updated." + elif ret == 2: # noqa: PLR2004 + msg = f"Internal error: {get_error_message()}" + elif ret == 3: # noqa: PLR2004 + msg = "Invalid C string." + else: + msg = "Unknown error." + raise RuntimeError(msg) + + +def with_request(interaction: InteractionHandle, method: str, path: str) -> None: + r""" + Configures the request for the Interaction. + + [Rust + `pactffi_with_request`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_request) + + Args: + interaction: + Handle to the Interaction. + + method: + The request HTTP method. + + path: + The request path. + + This may be a simple string in which case it will be used as-is, or + it may be a [JSON matching + rule](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.5.4/rust/pact_ffi/IntegrationJson.md) + which allows regex patterns. For examples: + + ```json + { + "value": "/path/to/100", + "pact:matcher:type": "regex", + "regex": "/path/to/\\d+" + } + ``` + + Raises: + RuntimeError: + If the request could not be specified. + """ + success: bool = lib.pactffi_with_request( + interaction._ref, + method.encode("utf-8"), + path.encode("utf-8"), + ) + if not success: + msg = f"The request '{method} {path}' could not be specified for {interaction}." + raise RuntimeError(msg) + + +def with_query_parameter_v2( + interaction: InteractionHandle, + name: str, + index: int, + value: str, +) -> None: + r""" + Configures a query parameter for the Interaction. + + [Rust + `pactffi_with_query_parameter_v2`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_query_parameter_v2) + + To setup a query parameter with multiple values, you can either call this + function multiple times with a different index value: + + ```python + with_query_parameter_v2(handle, "version", 0, "2") + with_query_parameter_v2(handle, "version", 0, "3") + ``` + + Or you can call it once with a JSON value that contains multiple values: + + ```python + with_query_parameter_v2( + handle, + "version", + 0, + json.dumps({"value": ["2", "3"]}), + ) + ``` + + The JSON value can also contain a matcher, which will be used to match the + query parameter value. For example, a semver matcher might look like this: + + ```python + with_query_parameter_v2( + handle, + "version", + 0, + json.dumps({ + "value": "1.2.3", + "pact:matcher:type": "regex", + "regex": r"\d+\.\d+\.\d+", + }), + ) + ``` + + See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.5.4/rust/pact_ffi/IntegrationJson.md) + + If you want the matching rules to apply to all values (and not just the one + with the given index), make sure to set the value to be an array. + + ```python + with_query_parameter_v2( + handle, + "id", + 0, + json.dumps({ + "value": ["2"], + "pact:matcher:type": "regex", + "regex": r"\d+", + }), + ) + ``` + + For query parameters with no value, two distinct formats are provided: + + 1. Parameters with blank values, as specified by `?foo=&bar=`, require an + empty string: + + ```python + with_query_parameter_v2(handle, "foo", 0, "") + with_query_parameter_v2(handle, "bar", 0, "") + ``` + + 2. Parameters with no associated value, as specified by `?foo&bar`, require + a NULL pointer: + + ```python + with_query_parameter_v2(handle, "foo", 0, None) + with_query_parameter_v2(handle, "bar", 0, None) + ``` + + Args: + interaction: + Handle to the Interaction. + + name: + The query parameter name. + + index: + The index of the value (starts at 0). You can use this to create a + query parameter with multiple values. + + value: + The query parameter value. + + This may be a simple string in which case it will be used as-is, or + it may be a [JSON matching + rule](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.5.4/rust/pact_ffi/IntegrationJson.md). + + Raises: + RuntimeError: + If there was an error setting the query parameter. + """ + success: bool = lib.pactffi_with_query_parameter_v2( + interaction._ref, + name.encode("utf-8"), + index, + value.encode("utf-8"), + ) + if not success: + msg = f"Failed to add query parameter {name} to request {interaction}." + raise RuntimeError(msg) + + +def with_specification(pact: PactHandle, version: PactSpecification) -> None: + """ + Sets the specification version for a given Pact model. + + [Rust + `pactffi_with_specification`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_specification) + + Args: + pact: + Handle to a Pact model. + + version: + The spec version to use. + + Raises: + RuntimeError: + If the Pact specification could not be set. + """ + success: bool = lib.pactffi_with_specification(pact._ref, version.value) + if not success: + msg = f"Failed to set Pact specification for {pact}" + raise RuntimeError(msg) + + +def handle_get_pact_spec_version(handle: PactHandle) -> PactSpecification: + """ + Fetches the Pact specification version for the given Pact model. + + [Rust + `pactffi_handle_get_pact_spec_version`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_handle_get_pact_spec_version) + + Args: + handle: + Handle to a Pact model. + + Returns: + The spec version for the Pact model. + """ + return PactSpecification(lib.pactffi_handle_get_pact_spec_version(handle._ref)) + + +def with_pact_metadata( + pact: PactHandle, + namespace: str, + name: str, + value: str, +) -> None: + """ + Sets the additional metadata on the Pact file. + + Common uses are to add the client library details such as the name and + version. Returns false if the interaction or Pact can't be modified (i.e. + the mock server for it has already started) or the namespace is readonly. + + [Rust + `pactffi_with_pact_metadata`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_pact_metadata) + + Args: + pact: + Handle to a Pact model + + namespace: + the top level metadata key to set any key values on + + name: + The key to set + + value: + The value to set + + Raises: + RuntimeError: + If the metadata could not be set. + """ + success: bool = lib.pactffi_with_pact_metadata( + pact._ref, + namespace.encode("utf-8"), + name.encode("utf-8"), + value.encode("utf-8"), + ) + if not success: + msg = f"Failed to set Pact metadata for {pact} with {namespace}.{name}={value}" + raise RuntimeError(msg) + + +def with_metadata( + interaction: InteractionHandle, + key: str, + value: str | None, + part: InteractionPart, +) -> None: + r""" + Adds metadata to the interaction. + + Metadata is only relevant for message interactions to provide additional + information about the message, such as the queue name, message type, tags, + timestamps, etc. + + * `key` - metadata key + * `value` - metadata value, supports JSON structures with matchers and + generators. Passing a `NULL` point will remove the metadata key instead. + * `part` - the part of the interaction to add the metadata to (only + relevant for synchronous message interactions). + + Returns `true` if the metadata was added successfully, `false` otherwise. + + To include matching rules for the value, include the matching rule JSON + format with the value as a single JSON document. I.e. + + ```python + with_metadata( + handle, + "TagData", + json.dumps({ + "value": {"ID": "sjhdjkshsdjh", "weight": 100.5}, + "pact:matcher:type": "type", + }), + ) + ``` + + See + [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.5.4/rust/pact_ffi/IntegrationJson.md) + + # Note + + For HTTP interactions, use [`with_header_v2`][pact_ffi.with_header_v2] + instead. This function will not have any effect on HTTP interactions and + returns `false`. + + For synchronous message interactions, the `part` parameter is required to + specify whether the metadata should be added to the request or response + part. For responses which can have multiple messages, the metadata will be + set on all response messages. This also requires for responses to have been + defined in the interaction. + + The [`with_body`][pact_ffi.with_body] will also contribute to the + metadata of the message (both sync and async) by setting the key + `contentType` with the content type of the message. + + # Safety + + The key and value parameters must be valid pointers to NULL terminated + strings, or `NULL` for the value parameter if the metadata key should be + removed. + + Raises: + RuntimeError: + If the metadata could not be set. + """ + success: bool = lib.pactffi_with_metadata( + interaction._ref, + key.encode("utf-8"), + value.encode("utf-8") if value is not None else ffi.NULL, + part.value, + ) + if not success: + msg = f"Failed to set metadata for {interaction} with {key}={value}" + raise RuntimeError(msg) + + +def with_header_v2( + interaction: InteractionHandle, + part: InteractionPart, + name: str, + index: int, + value: str, +) -> None: + r""" + Configures a header for the Interaction. + + [Rust `pactffi_with_header_v2`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_header_v2) + + To setup a header with multiple values, you can either call this + function multiple times with a different index value: + + ```python + with_header_v2(handle, part, "Accept-Version", 0, "2") + with_header_v2(handle, part, "Accept-Version", 0, "3") + ``` + + Or you can call it once with a JSON value that contains multiple values: + + ```python + with_header_v2( + handle, + part, + "Accept-Version", + 0, + json.dumps({"value": ["2", "3"]}), + ) + ``` + + The JSON value can also contain a matcher, which will be used to match the + query parameter value. For example, a semver matcher might look like this: + + ```python + with_query_parameter_v2( + handle, + "Accept-Version", + 0, + json.dumps({ + "value": "1.2.3", + "pact:matcher:type": "regex", + "regex": r"\d+\.\d+\.\d+", + }), + ) + ``` + + See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.5.4/rust/pact_ffi/IntegrationJson.md) + + Args: + interaction: + Handle to the Interaction. + + part: + The part of the interaction to add the header to (Request or + Response). + + name: + The header name. This is case insensitive. + + index: + The index of the value (starts at 0). You can use this to create a + header with multiple values. + + value: + The header value. + + This may be a simple string in which case it will be used as-is, or + it may be a [JSON matching + rule](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.5.4/rust/pact_ffi/IntegrationJson.md). + + Raises: + RuntimeError: + If there was an error setting the header. + """ + success: bool = lib.pactffi_with_header_v2( + interaction._ref, + part.value, + name.encode("utf-8"), + index, + value.encode("utf-8"), + ) + if not success: + msg = f"The header {name!r} could not be specified for {interaction}." + raise RuntimeError(msg) + + +def set_header( + interaction: InteractionHandle, + part: InteractionPart, + name: str, + value: str, +) -> None: + """ + Sets a header for the Interaction. + + Note that this function will overwrite any previously set header values. + Also, this function will not process the value in any way, so matching rules + and generators can not be configured with it. + + [Rust + `pactffi_set_header`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_set_header) + + If matching rules are required to be set, use `pactffi_with_header_v2`. + + Args: + interaction: + Handle to the Interaction. + + part: + The part of the interaction to add the header to (Request or + Response). + + name: + The header name. This is case insensitive. + + value: + The header value. This is handled as-is, with no processing. + + Raises: + RuntimeError: + If the header could not be set. + """ + success: bool = lib.pactffi_set_header( + interaction._ref, + part.value, + name.encode("utf-8"), + value.encode("utf-8"), + ) + if not success: + msg = f"The header {name!r} could not be set for {interaction}." + raise RuntimeError(msg) + + +def response_status(interaction: InteractionHandle, status: int) -> None: + """ + Configures the response for the Interaction. + + [Rust + `pactffi_response_status`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_response_status) + + Args: + interaction: + Handle to the Interaction. + + status: + The response status. Defaults to 200. + + Raises: + RuntimeError: + If the response status could not be set. + """ + success: bool = lib.pactffi_response_status(interaction._ref, status) + if not success: + msg = f"The response status {status} could not be set for {interaction}." + raise RuntimeError(msg) + + +def response_status_v2(interaction: InteractionHandle, status: str) -> None: + """ + Configures the response for the Interaction. + + [Rust + `pactffi_response_status_v2`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_response_status_v2) + + To include matching rules for the status (only statusCode or integer really + makes sense to use), include the matching rule JSON format with the value as + a single JSON document. I.e. + + ```python + response_status_v2( + handle, + json.dumps({ + "pact:generator:type": "RandomInt", + "min": 100, + "max": 399, + "pact:matcher:type": "statusCode", + "status": "nonError", + }), + ) + ``` + + See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.5.4/rust/pact_ffi/IntegrationJson.md) + + Args: + interaction: + Handle to the Interaction. + + status: + The response status. Defaults to 200. + + This may be a simple string in which case it will be used as-is, or + it may be a [JSON matching + rule](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.5.4/rust/pact_ffi/IntegrationJson.md). + + Raises: + RuntimeError: + If the response status could not be set. + """ + success: bool = lib.pactffi_response_status_v2( + interaction._ref, status.encode("utf-8") + ) + if not success: + msg = f"The response status {status} could not be set for {interaction}." + raise RuntimeError(msg) + + +def with_body( + interaction: InteractionHandle, + part: InteractionPart, + content_type: str | None, + body: str | None, +) -> None: + """ + Adds the body for the interaction. + + [Rust + `pactffi_with_body`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_body) + + Returns false if the interaction or Pact can't be modified (i.e. the mock + server for it has already started) + + If the `content_type` is determined as follows, whichever is first: + + - The `content_type` argument to this function + - The `Content-Type` header for HTTP interaction, or `contentType` metadata + entry for message interactions. + - From automatic detection of the body contents. + - Defaults to `text/plain` as a last resort. + + Furthermore, the `Content-Type` header or `contentType` metadata entry will + be updated with the above determined content type, _unless_ it is already + set. + + This function will overwrite the body contents if they exist, with the + exception of the response part of synchronous message interactions, where a + new response will be appended. + + Args: + interaction: + Handle to the Interaction. + + part: + The part of the interaction to add the body to (Request or + Response). This is ignored for asynchronous message interactions. + + content_type: + The content type of the body, or `None` to use the internal logic. + + body: + The body contents. For JSON payloads, matching rules can be embedded + in the body. See + [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.5.4/rust/pact_ffi/IntegrationJson.md). + + Raises: + RuntimeError: + If the body could not be specified. + """ + success: bool = lib.pactffi_with_body( + interaction._ref, + part.value, + content_type.encode("utf-8") if content_type else ffi.NULL, + body.encode("utf-8") if body else None, + ) + if not success: + msg = f"Unable to set body for {interaction}." + raise RuntimeError(msg) + + +def with_binary_body( + interaction: InteractionHandle, + part: InteractionPart, + content_type: str | None, + body: bytes | None, +) -> None: + """ + Adds the body for the interaction. + + [Rust + `pactffi_with_binary_body`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_binary_body) + + For HTTP and async message interactions, this will overwrite the body. With + asynchronous messages, the part parameter will be ignored. With synchronous + messages, the request contents will be overwritten, while a new response + will be appended to the message. + + Args: + interaction: + Handle to the Interaction. + + part: + The part of the interaction to add the body to (Request or + Response). + + content_type: + The content type of the body. Will be ignored if a content type + header is already set. If `None`, the content type will be set to + `application/octet-stream`. + + body: + The body contents. If `None`, the body will be set to null. + + Raises: + RuntimeError: + If the body could not be modified. + """ + success: bool = lib.pactffi_with_binary_body( + interaction._ref, + part.value, + content_type.encode("utf-8") if content_type else ffi.NULL, + body or ffi.NULL, + len(body) if body else 0, + ) + if not success: + msg = f"Unable to set body for {interaction}." + raise RuntimeError(msg) + + +def with_binary_file( + interaction: InteractionHandle, + part: InteractionPart, + content_type: str | None, + body: bytes | None, +) -> None: + """ + Adds a binary file as the body with the expected content type and contents. + + /// warning + This function is deprecated. Use + [`with_binary_body`][pact_ffi.with_binary_body] in order to set the binary + body, and use [`with_matching_rules`][pact_ffi.with_matching_rules] to set + the matching rules to ensure that only the content type is being matched. + /// + + Will use a mime type matcher to match the body. Returns false if the + interaction or Pact can't be modified (i.e. the mock server for it has + already started) + + [Rust + `pactffi_with_binary_file`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_binary_file) + + For HTTP and async message interactions, this will overwrite the body. With + asynchronous messages, the part parameter will be ignored. With synchronous + messages, the request contents will be overwritten, while a new response + will be appended to the message. + + Args: + interaction: + Handle to the Interaction. + + part: + The part of the interaction to add the body to (Request or + Response). + + content_type: + The content type of the body. Will be ignored if a content type + header is already set. + + body: + The body contents. If `None`, the body will be set to null. + + Raises: + RuntimeError: + If the body could not be set. + """ + success: bool = lib.pactffi_with_binary_file( + interaction._ref, + part.value, + content_type.encode("utf-8") if content_type else ffi.NULL, + body or ffi.NULL, + len(body) if body else 0, + ) + if not success: + msg = f"Unable to set body for {interaction}." + raise RuntimeError(msg) + + +def with_matching_rules( + interaction: InteractionHandle, + part: InteractionPart, + rules: str, +) -> None: + """ + Add matching rules to the interaction. + + [Rust + `pactffi_with_matching_rules`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_matching_rules) + + This function can be called multiple times, in which case the matching + rules will be merged. + + Args: + interaction: + Handle to the Interaction. + + part: + Request or response part (if applicable). + + rules: + JSON string of the matching rules to add to the interaction. + + Raises: + RuntimeError: + If the rules could not be added. + """ + success: bool = lib.pactffi_with_matching_rules( + interaction._ref, + part.value, + rules.encode("utf-8"), + ) + if not success: + msg = f"Unable to set matching rules for {interaction}." + raise RuntimeError(msg) + + +def with_generators( + interaction: InteractionHandle, + part: InteractionPart, + generators: str, +) -> None: + """ + Add generators to the interaction. + + [Rust + `pactffi_with_generators`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_generators) + + This function can be called multiple times, in which case the generators + will be combined (provide they don't clash). + + For synchronous messages which allow multiple responses, the generators will + be added to all the responses. + + Args: + interaction: + Handle to the Interaction. + + part: + Request or response part (if applicable). + + generators: + JSON string of the generators to add to the interaction. + + Raises: + RuntimeError: + If the generators could not be added. + """ + success: bool = lib.pactffi_with_generators( + interaction._ref, + part.value, + generators.encode("utf-8"), + ) + if not success: + msg = f"Unable to set generators for {interaction}." + raise RuntimeError(msg) + + +def with_multipart_file_v2( # noqa: PLR0913 + interaction: InteractionHandle, + part: InteractionPart, + content_type: str | None, + file: Path | None, + part_name: str, + boundary: str | None, +) -> None: + """ + Adds a binary file as the body as a MIME multipart. + + Will use a mime type matcher to match the body. Returns an error if the + interaction or Pact can't be modified (i.e. the mock server for it has + already started) or an error occurs. + + [Rust + `pactffi_with_multipart_file_v2`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_multipart_file_v2) + + This function can be called multiple times. In that case, each subsequent + call will be appended to the existing multipart body as a new part. + + Args: + interaction: + Handle to the Interaction. + + part: + The part of the interaction to add the body to (Request or + Response). + + content_type: + The content type of the body. + + file: + Path to the file to add. If `None`, the body will be set to null. + + part_name: + Name for the mime part. + + boundary: + Boundary for the multipart separation. If `None`, a random string + will be used. + """ + result = StringResult( + lib.pactffi_with_multipart_file_v2( + interaction._ref, + part.value, + content_type.encode("utf-8") if content_type else ffi.NULL, + str(file).encode("utf-8") if file else ffi.NULL, + part_name.encode("utf-8"), + boundary.encode("utf-8") if boundary else ffi.NULL, + ), + ) + result.raise_exception() + + +def with_multipart_file( + interaction: InteractionHandle, + part: InteractionPart, + content_type: str, + file: str, + part_name: str, +) -> StringResult: + """ + Adds a binary file as the body as a MIME multipart. + + Will use a mime type matcher to match the body. Returns an error if the + interaction or Pact can't be modified (i.e. the mock server for it has + already started) or an error occurs. + + [Rust + `pactffi_with_multipart_file`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_with_multipart_file) + + * `interaction` - Interaction handle to set the body for. + * `part` - Request or response part. + * `content_type` - Expected content type of the file. + * `file` - path to the example file + * `part_name` - name for the mime part + + This function can be called multiple times. In that case, each subsequent + call will be appended to the existing multipart body as a new part. + + # Safety + + The content type, file path and part name must be valid pointers to UTF-8 + encoded NULL-terminated strings. Passing invalid pointers or pointers to + strings that are not NULL terminated will lead to undefined behaviour. + + # Error Handling + + If the file path is a NULL pointer, it will set the body contents as as an + empty mime-part. If the file path does not point to a valid file, or is not + able to be read, it will return an error result. If the content type is a + null pointer, or can't be parsed, it will return an error result. Returns an + error if the interaction or Pact can't be modified (i.e. the mock server for + it has already started), the interaction is not an HTTP interaction or some + other error occurs. + """ + # This function is intentionally left unimplemented. The + # `with_multipart_file_v2` function should be used instead. + raise NotImplementedError + + +def set_key(interaction: InteractionHandle, key: str | None) -> None: + """ + Sets the key attribute for the interaction. + + [Rust + `pactffi_set_key`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_set_key) + + Args: + interaction: + Interaction handle to modify. + + key: + Key value. This must be a valid UTF-8 null-terminated string, or + `None` to clear the key. + + Raises: + RuntimeError: + If the key could not be set. + """ + success: bool = lib.pactffi_set_key( + interaction._ref, + key.encode("utf-8") if key else ffi.NULL, + ) + if not success: + msg = f"Failed to set key for {interaction}." + raise RuntimeError(msg) + + +def set_pending(interaction: InteractionHandle, *, pending: bool) -> None: + """ + Mark the interaction as pending. + + [Rust + `pactffi_set_pending`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_set_pending) + + Args: + interaction: + Interaction handle to modify. + + pending: + Boolean value to toggle the pending state of the interaction. + + Raises: + RuntimeError: + If the pending status could not be updated. + """ + success: bool = lib.pactffi_set_pending(interaction._ref, pending) + if not success: + msg = f"Failed to update pending status for {interaction}." + raise RuntimeError(msg) + + +def set_comment(interaction: InteractionHandle, key: str, value: str | None) -> None: + """ + Add a comment to the interaction. + + [Rust + `pactffi_set_comment`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_set_comment) + + Args: + interaction: + Interaction handle to set the comments for. + + key: + Key value + + value: + Comment value. This may be any valid JSON value, or a `None` to + clear the comment. Note that a value that deserialize to a JSON null + will result in a comment being added, with the value being the JSON + null. + + Raises: + RuntimeError: + If the comments could not be updated. + """ + success: bool = lib.pactffi_set_comment( + interaction._ref, + key.encode("utf-8"), + value.encode("utf-8") if value else ffi.NULL, + ) + if not success: + msg = f"Failed to set comment for {interaction}." + raise RuntimeError(msg) + + +def add_text_comment(interaction: InteractionHandle, comment: str) -> None: + """ + Add a text comment to the interaction. + + [Rust + `pactffi_add_text_comment`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_add_text_comment) + + Args: + interaction: + Interaction handle to set the comments for. + + comment: + Comment value. This is a regular string value. + + Raises: + RuntimeError: + If the comment could not be added. + """ + success: bool = lib.pactffi_add_text_comment( + interaction._ref, + comment.encode("utf-8"), + ) + if not success: + msg = f"Failed to add text comment for {interaction}." + raise RuntimeError(msg) + + +def add_interaction_reference( + interaction: InteractionHandle, + group: str, + name: str, + value: str, +) -> None: + """ + Add an external reference to the interaction. + + The reference will be stored in the Pact file comments under the + `references` key, grouped by `group`. For instance, you could store the + AsyncAPI operation ID that the interaction corresponds to as an external + reference, or a pull request reference. + + [Rust + `pactffi_add_interaction_reference`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_add_interaction_reference) + + Args: + interaction: + Interaction handle to modify. + + group: + Group or system the reference belongs to (e.g. `"Jira"`, + `"OpenAPI"`, `"GitHub"`). + + name: + Name or identifier of the reference (e.g. `"TICKET"`, + `"OperationID"`, `"PullRequest"`). + + value: + Value of the reference, typically an ID (e.g., `"TICKET-123"`, + `"getUserById"`, `"#123"`). + + + + Args: + interaction: + Interaction handle to modify. + + group: + Group or system the reference belongs to (e.g. ``"Jira"``). + + name: + Name or identifier of the reference (e.g. ``"TICKET-123"``). + + value: + Value of the reference, typically an ID. + + Raises: + RuntimeError: + If the reference could not be added. + """ + success: bool = lib.pactffi_add_interaction_reference( + interaction._ref, + group.encode("utf-8"), + name.encode("utf-8"), + value.encode("utf-8"), + ) + if not success: + msg = f"Failed to add interaction reference for {interaction}." + raise RuntimeError(msg) + + +def pact_handle_get_async_message_iter(pact: PactHandle) -> PactAsyncMessageIterator: + r""" + Get an iterator over all the asynchronous messages of the Pact. + + The returned iterator needs to be freed with + `pactffi_pact_sync_message_iter_delete`. + + [Rust + `pactffi_pact_handle_get_sync_message_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_handle_get_sync_message_iter) + + # Safety + + The iterator contains a copy of the Pact, so it is always safe to use. + + # Error Handling + + On failure, this function will return a NULL pointer. + + This function may fail if any of the Rust strings contain embedded null + ('\0') bytes. + """ + return PactAsyncMessageIterator( + lib.pactffi_pact_handle_get_async_message_iter(pact._ref), + ) + + +def pact_handle_get_sync_message_iter(pact: PactHandle) -> PactSyncMessageIterator: + r""" + Get an iterator over all the synchronous messages of the Pact. + + The returned iterator needs to be freed with + `pactffi_pact_sync_message_iter_delete`. + + [Rust + `pactffi_pact_handle_get_sync_message_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_handle_get_sync_message_iter) + + # Safety + + The iterator contains a copy of the Pact, so it is always safe to use. + + # Error Handling + + On failure, this function will return a NULL pointer. + + This function may fail if any of the Rust strings contain embedded null + ('\0') bytes. + """ + return PactSyncMessageIterator( + lib.pactffi_pact_handle_get_sync_message_iter(pact._ref), + ) + + +def pact_handle_get_sync_http_iter(pact: PactHandle) -> PactSyncHttpIterator: + r""" + Get an iterator over all the synchronous HTTP request/response interactions. + + The returned iterator needs to be freed with + `pactffi_pact_sync_http_iter_delete`. + + [Rust + `pactffi_pact_handle_get_sync_http_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_handle_get_sync_http_iter) + + # Safety + + The iterator contains a copy of the Pact, so it is always safe to use. + + # Error Handling + + On failure, this function will return a NULL pointer. + + This function may fail if any of the Rust strings contain embedded null + ('\0') bytes. + """ + return PactSyncHttpIterator(lib.pactffi_pact_handle_get_sync_http_iter(pact._ref)) + + +def pact_handle_get_message_iter(pact: PactHandle) -> PactMessageIterator: + r""" + Get an iterator over all the interactions of the Pact. + + The returned iterator needs to be freed with + `pactffi_pact_message_iter_delete`. + + [Rust + `pactffi_pact_handle_get_message_iter`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_handle_get_message_iter) + + # Safety + + The iterator contains a copy of the Pact, so it is always safe to use. + + # Error Handling + + On failure, this function will return a NULL pointer. + + This function may fail if any of the Rust strings contain embedded null + ('\0') bytes. + """ + return PactMessageIterator( + lib.pactffi_pact_handle_get_message_iter(pact._ref), + ) + + +def pact_handle_write_file( + pact: PactHandle, + directory: Path | str | None, + *, + overwrite: bool, +) -> None: + """ + External interface to write out the pact file. + + [Rust + `pactffi_pact_handle_write_file`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_pact_handle_write_file) + + This function should be called if all the consumer tests have passed. + + Args: + pact: + Handle to a Pact model. + + directory: + The directory to write the file to. If `None`, the current working + directory is used. + + overwrite: + If `True`, the file will be overwritten with the contents of the + current pact. Otherwise, it will be merged with any existing pact + file. + + Raises: + RuntimeError: + If there was an error writing the pact file. + """ + ret: int = lib.pactffi_pact_handle_write_file( + pact._ref, + str(directory).encode("utf-8") if directory else ffi.NULL, + overwrite, + ) + if ret == 0: + return + if ret == 1: + msg = f"The function panicked while writing {pact} to {directory}." + elif ret == 2: # noqa: PLR2004 + msg = f"The pact file was not able to be written for {pact}." + elif ret == 3: # noqa: PLR2004 + msg = f"The pact for {pact} was not found." + else: + msg = f"Unknown error writing {pact} to {directory}." + raise RuntimeError(msg) + + +def free_pact_handle(pact: PactHandle) -> None: + """ + Delete a Pact handle and free the resources used by it. + + [Rust + `pactffi_free_pact_handle`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_free_pact_handle) + + Raises: + RuntimeError: + If the handle could not be freed. + """ + ret: int = lib.pactffi_free_pact_handle(pact._ref) + if ret == 0: + return + if ret == 1: + msg = f"{pact} is not valid or does not refer to a valid Pact." + else: + msg = f"There was an unknown error freeing {pact}." + raise RuntimeError(msg) + + +def verifier_new_for_application() -> VerifierHandle: + """ + Get a Handle to a newly created verifier. + + By default, verification results will not be published. To enable + publishing, use + [`pactffi_verifier_set_publish_options`][pact_ffi.verifier_set_publish_options] + to set the required values and enable it. + + [Rust + `pactffi_verifier_new_for_application`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_new_for_application) + """ + result: cffi.FFI.CData = lib.pactffi_verifier_new_for_application( + b"pact-python", + __version__.encode("utf-8"), + ) + return VerifierHandle(result) + + +def verifier_shutdown(handle: VerifierHandle) -> None: + """ + Shutdown the verifier and release all resources. + + [Rust `pactffi_verifier_shutdown`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_shutdown) + """ + lib.pactffi_verifier_shutdown(handle._ref) + + +def verifier_set_provider_info( # noqa: PLR0913 + handle: VerifierHandle, + name: str | None, + scheme: str | None, + host: str | None, + port: int | None, + path: str | None, +) -> None: + """ + Set the provider details for the Pact verifier. + + [Rust + `pactffi_verifier_set_provider_info`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_set_provider_info) + + Args: + handle: + The verifier handle to update. + + name: + A user-friendly name to describe the provider. + + scheme: + Determine the scheme to use, typically one of `HTTP` or `HTTPS`. + + host: + The host of the provider. This may be either a hostname to resolve, + or an IP address. + + port: + The port of the provider. + + path: + The path of the provider. + + If any value is `None`, the default value as determined by the underlying + FFI library will be used. + """ + lib.pactffi_verifier_set_provider_info( + handle._ref, + name.encode("utf-8") if name else ffi.NULL, + scheme.encode("utf-8") if scheme else ffi.NULL, + host.encode("utf-8") if host else ffi.NULL, + port, + path.encode("utf-8") if path else ffi.NULL, + ) + + +def verifier_add_provider_transport( + handle: VerifierHandle, + protocol: str | None, + port: int, + path: str | None, + scheme: str | None, +) -> None: + """ + Adds a new transport for the given provider. + + [Rust + `pactffi_verifier_add_provider_transport`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_add_provider_transport) + + Args: + handle: + The verifier handle to update. + + protocol: + In this context, the kind of + + port: + The port of the provider. + + path: + The path of the provider. + + scheme: + The scheme to use, typically one of `HTTP` or `HTTPS`. + + If any value is `None`, the default value as determined by the underlying + FFI library will be used. + """ + lib.pactffi_verifier_add_provider_transport( + handle._ref, + protocol.encode("utf-8") if protocol else ffi.NULL, + port, + path.encode("utf-8") if path else ffi.NULL, + scheme.encode("utf-8") if scheme else ffi.NULL, + ) + + +def verifier_set_filter_info( + handle: VerifierHandle, + filter_description: str | None, + filter_state: str | None, + *, + filter_no_state: bool, +) -> None: + """ + Set the filters for the Pact verifier. + + [Rust + `pactffi_verifier_set_filter_info`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_set_filter_info) + + Set filters to narrow down the interactions to verify. + + Args: + handle: + The verifier handle to update. + + filter_description: + A regular expression to filter the interactions by description. + + filter_state: + A regular expression to filter the interactions by state. + + filter_no_state: + If `True`, the option to filter by state will be turned on. + """ + lib.pactffi_verifier_set_filter_info( + handle._ref, + filter_description.encode("utf-8") if filter_description else ffi.NULL, + filter_state.encode("utf-8") if filter_state else ffi.NULL, + filter_no_state, + ) + + +def verifier_set_provider_state( + handle: VerifierHandle, + url: str, + *, + teardown: bool, + body: bool, +) -> None: + """ + Set the provider state URL for the Pact verifier. + + [Rust + `pactffi_verifier_set_provider_state`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_set_provider_state) + + Args: + handle: + The verifier handle to update. + + url: + The URL to use for the provider state. + + teardown: + If teardown state change requests should be made after an + interaction is validated. + + body: + If state change request data should be sent in the body or the + query. + """ + lib.pactffi_verifier_set_provider_state( + handle._ref, + url.encode("utf-8"), + teardown, + body, + ) + + +def verifier_set_verification_options( + handle: VerifierHandle, + *, + disable_ssl_verification: bool, + request_timeout: int, +) -> None: + """ + Set the options used by the verifier when calling the provider. + + [Rust + `pactffi_verifier_set_verification_options`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_set_verification_options) + + Args: + handle: + The verifier handle to update. + + disable_ssl_verification: + If SSL verification should be disabled. + + request_timeout: + The timeout for the request in milliseconds. + + Raises: + RuntimeError: + If the options could not be set. + """ + retval: int = lib.pactffi_verifier_set_verification_options( + handle._ref, + disable_ssl_verification, + request_timeout, + ) + if retval != 0: + msg = f"Failed to set verification options for {handle}." + raise RuntimeError(msg) + + +def verifier_set_coloured_output( + handle: VerifierHandle, + *, + enabled: bool, +) -> None: + """ + Enables or disables coloured output using ANSI escape codes. + + [Rust + `pactffi_verifier_set_coloured_output`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_set_coloured_output) + + By default, coloured output is enabled. + + Args: + handle: + The verifier handle to update. + + enabled: + A boolean value to enable or disable coloured output. + + Raises: + RuntimeError: + If the coloured output could not be set. + """ + retval: int = lib.pactffi_verifier_set_coloured_output( + handle._ref, + enabled, + ) + if retval != 0: + msg = f"Failed to set coloured output for {handle}." + raise RuntimeError(msg) + + +def verifier_set_no_pacts_is_error(handle: VerifierHandle, *, enabled: bool) -> None: + """ + Enables or disables if no pacts are found to verify results in an error. + + [Rust + `pactffi_verifier_set_no_pacts_is_error`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_set_no_pacts_is_error) + + Args: + handle: + The verifier handle to update. + + enabled: + If `True`, an error will be raised when no pacts are found to verify. + + Raises: + RuntimeError: + If the no pacts is error setting could not be set. + """ + retval: int = lib.pactffi_verifier_set_no_pacts_is_error( + handle._ref, + enabled, + ) + if retval != 0: + msg = f"Failed to set no pacts is error for {handle}." + raise RuntimeError(msg) + + +def verifier_set_publish_options( + handle: VerifierHandle, + provider_version: str, + build_url: str | None, + provider_tags: list[str] | None, + provider_branch: str | None, +) -> None: + """ + Set the options used when publishing verification results to the Broker. + + [Rust + `pactffi_verifier_set_publish_options`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_set_publish_options) + + Args: + handle: + The verifier handle to update. + + provider_version: + Version of the provider to publish. + + build_url: + URL to the build which ran the verification. + + provider_tags: + Collection of tags for the provider. + + provider_branch: + Name of the branch used for verification. + + Raises: + RuntimeError: + If the publish options could not be set. + """ + retval: int = lib.pactffi_verifier_set_publish_options( + handle._ref, + provider_version.encode("utf-8"), + build_url.encode("utf-8") if build_url else ffi.NULL, + [ffi.new("char[]", t.encode("utf-8")) for t in provider_tags or []], + len(provider_tags or []), + provider_branch.encode("utf-8") if provider_branch else ffi.NULL, + ) + if retval != 0: + msg = f"Failed to set publish options for {handle}." + raise RuntimeError(msg) + + +def verifier_set_consumer_filters( + handle: VerifierHandle, + consumer_filters: Collection[str], +) -> None: + """ + Set the consumer filters for the Pact verifier. + + [Rust + `pactffi_verifier_set_consumer_filters`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_set_consumer_filters) + """ + lib.pactffi_verifier_set_consumer_filters( + handle._ref, + [ffi.new("char[]", f.encode("utf-8")) for f in consumer_filters], + len(consumer_filters), + ) + + +def verifier_add_custom_header( + handle: VerifierHandle, + header_name: str, + header_value: str, +) -> None: + """ + Adds a custom header to be added to the requests made to the provider. + + [Rust + `pactffi_verifier_add_custom_header`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_add_custom_header) + """ + lib.pactffi_verifier_add_custom_header( + handle._ref, + header_name.encode("utf-8"), + header_value.encode("utf-8"), + ) + + +def verifier_set_follow_redirects( + handle: VerifierHandle, + *, + follow: bool, +) -> None: + """ + Sets whether redirects should be automatically followed. + + [Rust + `pactffi_verifier_set_follow_redirects`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_set_follow_redirects) + + Args: + handle: + The verifier handle to update. + + follow: + If `True`, redirects will be automatically followed when making + requests to the provider. + """ + lib.pactffi_verifier_set_follow_redirects( + handle._ref, + 1 if follow else 0, + ) + + +def verifier_add_file_source(handle: VerifierHandle, file: str) -> None: + """ + Adds a Pact file as a source to verify. + + [Rust + `pactffi_verifier_add_file_source`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_add_file_source) + """ + lib.pactffi_verifier_add_file_source(handle._ref, file.encode("utf-8")) + + +def verifier_add_directory_source(handle: VerifierHandle, directory: str) -> None: + """ + Adds a Pact directory as a source to verify. + + All pacts from the directory that match the provider name will be verified. + + [Rust + `pactffi_verifier_add_directory_source`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_add_directory_source) + + # Safety + + All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced + with U+FFFD REPLACEMENT CHARACTER. + + """ + lib.pactffi_verifier_add_directory_source(handle._ref, directory.encode("utf-8")) + + +def verifier_url_source( + handle: VerifierHandle, + url: str, + username: str | None, + password: str | None, + token: str | None, +) -> None: + """ + Adds a URL as a source to verify. + + [Rust + `pactffi_verifier_url_source`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_url_source) + + Args: + handle: + The verifier handle to update. + + url: + The URL to use as a source for the verifier. + + username: + The username to use when fetching pacts from the URL. + + password: + The password to use when fetching pacts from the URL. + + token: + The token to use when fetching pacts from the URL. This will be used + as a bearer token. It is mutually exclusive with the username and + password. + """ + lib.pactffi_verifier_url_source( + handle._ref, + url.encode("utf-8"), + username.encode("utf-8") if username else ffi.NULL, + password.encode("utf-8") if password else ffi.NULL, + token.encode("utf-8") if token else ffi.NULL, + ) + + +def verifier_broker_source( + handle: VerifierHandle, + url: str, + username: str | None, + password: str | None, + token: str | None, +) -> None: + """ + Adds a Pact broker as a source to verify. + + [Rust + `pactffi_verifier_broker_source`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_broker_source) + + This will fetch all the pact files from the broker that match the provider + name. + + Args: + handle: + The verifier handle to update. + + url: + The URL to use as a source for the verifier. + + username: + The username to use when fetching pacts from the broker. + + password: + The password to use when fetching pacts from the broker. + + token: + The token to use when fetching pacts from the broker. This will be + used as a bearer token. + """ + lib.pactffi_verifier_broker_source( + handle._ref, + url.encode("utf-8"), + username.encode("utf-8") if username else ffi.NULL, + password.encode("utf-8") if password else ffi.NULL, + token.encode("utf-8") if token else ffi.NULL, + ) + + +def verifier_broker_source_with_selectors( # noqa: PLR0913 + handle: VerifierHandle, + url: str, + username: str | None, + password: str | None, + token: str | None, + enable_pending: int, + include_wip_pacts_since: datetime.date | None, + provider_tags: list[str], + provider_branch: str | None, + consumer_version_selectors: list[str], + consumer_version_tags: list[str], +) -> None: + """ + Adds a Pact broker as a source to verify. + + [Rust + `pactffi_verifier_broker_source_with_selectors`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_broker_source_with_selectors) + + This will fetch all the pact files from the broker that match the provider + name and the consumer version selectors (See [Consumer Version + Selectors](https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/)). + + If a username and password is given, then basic authentication will be used + when fetching the pact file. If a token is provided, then bearer token + authentication will be used. + + Args: + handle: + The verifier handle to update. + + url: + The URL to use as a source for the verifier. + + username: + The username to use when fetching pacts from the broker. + + password: + The password to use when fetching pacts from the broker. + + token: + The token to use when fetching pacts from the broker. This will be + used as a bearer token. + + enable_pending: + If pending pacts should be included in the verification process. + + include_wip_pacts_since: + The date to use to filter out WIP pacts. + + provider_tags: + The tags to use to filter the provider pacts. + + provider_branch: + The branch to use to filter the provider pacts. + + consumer_version_selectors: + The consumer version selectors to use to filter the consumer pacts. + This must be passed in as a JSON string. + + consumer_version_tags: + The tags to use to filter the consumer pacts. + """ + ret: int = lib.pactffi_verifier_broker_source_with_selectors( + handle._ref, + url.encode("utf-8"), + username.encode("utf-8") if username else ffi.NULL, + password.encode("utf-8") if password else ffi.NULL, + token.encode("utf-8") if token else ffi.NULL, + enable_pending, + ( + include_wip_pacts_since.isoformat().encode("utf-8") + if include_wip_pacts_since + else ffi.NULL + ), + [ffi.new("char[]", t.encode("utf-8")) for t in provider_tags], + len(provider_tags), + provider_branch.encode("utf-8") if provider_branch else ffi.NULL, + [ffi.new("char[]", s.encode("utf-8")) for s in consumer_version_selectors], + len(consumer_version_selectors), + [ffi.new("char[]", t.encode("utf-8")) for t in consumer_version_tags], + len(consumer_version_tags), + ) + if ret == 0: + return + if ret == -1: + msg = "Invalid version selector JSON." + raise ValueError(msg) + msg = "Unknown error adding broker source with selectors." + raise RuntimeError(msg) + + +def verifier_execute(handle: VerifierHandle) -> None: + """ + Runs the verification. + + [Rust + `pactffi_verifier_execute`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_execute) + + Raises: + RuntimeError: + If the verifier could not be executed. + """ + success: int = lib.pactffi_verifier_execute(handle._ref) + if success != 0: + msg = f"Failed to execute verifier for {handle}." + raise RuntimeError(msg) + + +def verifier_logs(handle: VerifierHandle) -> OwnedString: + """ + Extracts the logs for the verification run. + + [Rust + `pactffi_verifier_logs`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_logs) + + This needs the memory buffer log sink to be setup before the verification is + executed. The returned string will need to be freed with the `free_string` + function call to avoid leaking memory. + + Raises: + RuntimeError: + If the logs could not be extracted. + """ + ptr = lib.pactffi_verifier_logs(handle._ref) + if ptr == ffi.NULL: + msg = f"Failed to get logs for {handle}." + raise RuntimeError(msg) + return OwnedString(ptr) + + +def verifier_logs_for_provider(provider_name: str) -> OwnedString: + """ + Extracts the logs for the verification run for the provider name. + + [Rust + `pactffi_verifier_logs_for_provider`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_logs_for_provider) + + This needs the memory buffer log sink to be setup before the verification is + executed. The returned string will need to be freed with the `free_string` + function call to avoid leaking memory. + + Raises: + RuntimeError: + If the logs could not be extracted. + """ + ptr = lib.pactffi_verifier_logs_for_provider(provider_name.encode("utf-8")) + if ptr == ffi.NULL: + msg = f"Failed to get logs for {provider_name}." + raise RuntimeError(msg) + return OwnedString(ptr) + + +def verifier_output(handle: VerifierHandle, strip_ansi: int) -> OwnedString: + """ + Extracts the standard output for the verification run. + + [Rust + `pactffi_verifier_output`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_output) + + Args: + handle: + The verifier handle to update. + + strip_ansi: + This parameter controls ANSI escape codes. Setting it to a non-zero + value will cause the ANSI control codes to be stripped from the + output. + + Raises: + RuntimeError: + If the output could not be extracted. + """ + ptr = lib.pactffi_verifier_output(handle._ref, strip_ansi) + if ptr == ffi.NULL: + msg = f"Failed to get output for {handle}." + raise RuntimeError(msg) + return OwnedString(ptr) + + +def verifier_json(handle: VerifierHandle) -> OwnedString: + """ + Extracts the verification result as a JSON document. + + [Rust + `pactffi_verifier_json`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_verifier_json) + + Raises: + RuntimeError: + If the JSON could not be extracted. + """ + ptr = lib.pactffi_verifier_json(handle._ref) + if ptr == ffi.NULL: + msg = f"Failed to get JSON for {handle}." + raise RuntimeError(msg) + return OwnedString(ptr) + + +def using_plugin_with_delay( + pact: PactHandle, + plugin_name: str, + plugin_version: str | None, + completion_delay: int, +) -> None: + """ + Add a plugin to be used by the test. + + The plugin needs to be installed correctly for this function to work. + + Note that plugins run as separate processes, so will need to be cleaned up + afterwards by calling [`cleanup_plugins`][pact_ffi.cleanup_plugins] + otherwise you will have plugin processes left running. + + [Rust `pactffi_using_plugin_with_delay`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_using_plugin_with_delay) + + Args: + pact: + Handle to a Pact model. + + plugin_name: + Name of the plugin to use. + + plugin_version: + Version of the plugin to use. If `None`, the latest version will be + used. + + completion_delay: + An arbitrary delay specified in milliseconds to add before the + function returns to allow asynchronous tasks to complete. + + Raises: + RuntimeError: + If the plugin could not be loaded. + """ + ret = lib.pactffi_using_plugin_with_delay( + pact._ref, + plugin_name.encode("utf-8"), + plugin_version.encode("utf-8") if plugin_version else ffi.NULL, + completion_delay, + ) + if ret == 0: + return + if ret == 1: + msg = f"A general panic was caught: {get_error_message()}" + elif ret == 2: # noqa: PLR2004 + msg = f"Failed to load the plugin {plugin_name}." + elif ret == 3: # noqa: PLR2004 + msg = f"The Pact handle {pact} is invalid." + else: + msg = f"There was an unknown error loading the plugin {plugin_name}." + raise RuntimeError(msg) + + +def using_plugin( + pact: PactHandle, + plugin_name: str, + plugin_version: str | None, +) -> None: + """ + Add a plugin to be used by the test. + + The plugin needs to be installed correctly for this function to work. + + Note that plugins run as separate processes, so will need to be cleaned up + afterwards by calling [`cleanup_plugins`][pact_ffi.cleanup_plugins] + otherwise you will have plugin processes left running. + + [Rust + `pactffi_using_plugin`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_using_plugin) + + Args: + pact: + Handle to a Pact model. + + plugin_name: + Name of the plugin to use. + + plugin_version: + Version of the plugin to use. If `None`, the latest version will be + used. + + Raises: + RuntimeError: + If the plugin could not be loaded. + """ + ret: int = lib.pactffi_using_plugin( + pact._ref, + plugin_name.encode("utf-8"), + plugin_version.encode("utf-8") if plugin_version else ffi.NULL, + ) + if ret == 0: + return + if ret == 1: + msg = f"A general panic was caught: {get_error_message()}" + elif ret == 2: # noqa: PLR2004 + msg = f"Failed to load the plugin {plugin_name}." + elif ret == 3: # noqa: PLR2004 + msg = f"The Pact handle {pact} is invalid." + else: + msg = f"There was an unknown error loading the plugin {plugin_name}." + raise RuntimeError(msg) + + +def cleanup_plugins(pact: PactHandle) -> None: + """ + Decrement the access count on any plugins that are loaded for the Pact. + + This will shutdown any plugins that are no longer required (access count is + zero). + + [Rust + `pactffi_cleanup_plugins`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_cleanup_plugins) + """ + lib.pactffi_cleanup_plugins(pact._ref) + + +def interaction_contents( + interaction: InteractionHandle, + part: InteractionPart, + content_type: str, + contents: str, +) -> None: + """ + Setup the interaction part using a plugin. + + The contents is a JSON string that will be passed on to the plugin to + configure the interaction part. Refer to the plugin documentation on the + format of the JSON contents. + + [Rust + `pactffi_interaction_contents`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_interaction_contents) + + Args: + interaction: + Handle to the interaction to configure. + + part: + The part of the interaction to configure (request or response). It + is ignored for messages. + + content_type: + Mime type of the contents. + + contents: + JSON contents that gets passed to the plugin. + + Raises: + RuntimeError: + If the interaction could not be configured + """ + ret: int = lib.pactffi_interaction_contents( + interaction._ref, + part.value, + content_type.encode("utf-8"), + contents.encode("utf-8"), + ) + if ret == 0: + return + if ret == 1: + msg = f"A general panic was caught: {get_error_message()}" + elif ret == 2: # noqa: PLR2004 + msg = "The mock server has already been started." + elif ret == 3: # noqa: PLR2004 + msg = f"The interaction handle {interaction} is invalid." + elif ret == 4: # noqa: PLR2004 + msg = f"The content type {content_type} is not valid." + elif ret == 5: # noqa: PLR2004 + msg = "The content is not valid JSON." + elif ret == 6: # noqa: PLR2004 + msg = f"The plugin returned an error: {get_error_message()}" + else: + msg = f"There was an unknown error configuring the interaction: {ret}" + raise RuntimeError(msg) + + +def matches_string_value( + matching_rule: MatchingRule, + expected_value: str, + actual_value: str, + cascaded: int, +) -> OwnedString: + """ + Determines if the string value matches the given matching rule. + + If the value matches OK, will return a NULL pointer. If the value does not + match, will return a error message as a NULL terminated string. The error + message pointer will need to be deleted with the `pactffi_string_delete` + function once it is no longer required. + + [Rust + `pactffi_matches_string_value`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matches_string_value) + + * matching_rule - pointer to a matching rule + * expected_value - value we expect to get as a NULL terminated string + * actual_value - value to match as a NULL terminated string + * cascaded - if the matching rule has been cascaded from a parent. 0 == + false, 1 == true + + # Safety + + The matching rule pointer must be a valid pointer, and the value parameters + must be valid pointers to a NULL terminated strings. + """ + raise NotImplementedError + + +def matches_u64_value( + matching_rule: MatchingRule, + expected_value: int, + actual_value: int, + cascaded: int, +) -> OwnedString: + """ + Determines if the unsigned integer value matches the given matching rule. + + If the value matches OK, will return a NULL pointer. If the value does not + match, will return a error message as a NULL terminated string. The error + message pointer will need to be deleted with the `pactffi_string_delete` + function once it is no longer required. + + [Rust + `pactffi_matches_u64_value`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matches_u64_value) + + * matching_rule - pointer to a matching rule + * expected_value - value we expect to get + * actual_value - value to match + * cascaded - if the matching rule has been cascaded from a parent. 0 == + false, 1 == true + + # Safety + + The matching rule pointer must be a valid pointer. + """ + raise NotImplementedError + + +def matches_i64_value( + matching_rule: MatchingRule, + expected_value: int, + actual_value: int, + cascaded: int, +) -> OwnedString: + """ + Determines if the signed integer value matches the given matching rule. + + If the value matches OK, will return a NULL pointer. If the value does not + match, will return a error message as a NULL terminated string. The error + message pointer will need to be deleted with the `pactffi_string_delete` + function once it is no longer required. + + [Rust + `pactffi_matches_i64_value`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matches_i64_value) + + * matching_rule - pointer to a matching rule + * expected_value - value we expect to get + * actual_value - value to match + * cascaded - if the matching rule has been cascaded from a parent. 0 == + false, 1 == true + + # Safety + + The matching rule pointer must be a valid pointer. + """ + raise NotImplementedError + + +def matches_f64_value( + matching_rule: MatchingRule, + expected_value: float, + actual_value: float, + cascaded: int, +) -> OwnedString: + """ + Determines if the floating point value matches the given matching rule. + + If the value matches OK, will return a NULL pointer. If the value does not + match, will return a error message as a NULL terminated string. The error + message pointer will need to be deleted with the `pactffi_string_delete` + function once it is no longer required. + + [Rust + `pactffi_matches_f64_value`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matches_f64_value) + + * matching_rule - pointer to a matching rule + * expected_value - value we expect to get + * actual_value - value to match + * cascaded - if the matching rule has been cascaded from a parent. 0 == + false, 1 == true + + # Safety + + The matching rule pointer must be a valid pointer. + """ + raise NotImplementedError + + +def matches_bool_value( + matching_rule: MatchingRule, + expected_value: int, + actual_value: int, + cascaded: int, +) -> OwnedString: + """ + Determines if the boolean value matches the given matching rule. + + If the value matches OK, will return a NULL pointer. If the value does not + match, will return a error message as a NULL terminated string. The error + message pointer will need to be deleted with the `pactffi_string_delete` + function once it is no longer required. + + [Rust + `pactffi_matches_bool_value`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matches_bool_value) + + * matching_rule - pointer to a matching rule + * expected_value - value we expect to get, 0 == false and 1 == true + * actual_value - value to match, 0 == false and 1 == true + * cascaded - if the matching rule has been cascaded from a parent. 0 == + false, 1 == true + + # Safety + + The matching rule pointer must be a valid pointer. + """ + raise NotImplementedError + + +def matches_binary_value( # noqa: PLR0913 + matching_rule: MatchingRule, + expected_value: str, + expected_value_len: int, + actual_value: str, + actual_value_len: int, + cascaded: int, +) -> OwnedString: + """ + Determines if the binary value matches the given matching rule. + + If the value matches OK, will return a NULL pointer. If the value does not + match, will return a error message as a NULL terminated string. The error + message pointer will need to be deleted with the `pactffi_string_delete` + function once it is no longer required. + + [Rust + `pactffi_matches_binary_value`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matches_binary_value) + + * matching_rule - pointer to a matching rule + * expected_value - value we expect to get + * expected_value_len - length of the expected value bytes + * actual_value - value to match + * actual_value_len - length of the actual value bytes + * cascaded - if the matching rule has been cascaded from a parent. 0 == + false, 1 == true + + # Safety + + The matching rule, expected value and actual value pointers must be a valid + pointers. expected_value_len and actual_value_len must contain the number of + bytes that the value pointers point to. Passing invalid lengths can lead to + undefined behaviour. + """ + raise NotImplementedError + + +def matches_json_value( + matching_rule: MatchingRule, + expected_value: str, + actual_value: str, + cascaded: int, +) -> OwnedString: + """ + Determines if the JSON value matches the given matching rule. + + If the value matches OK, will return a NULL pointer. If the value does not + match, will return a error message as a NULL terminated string. The error + message pointer will need to be deleted with the `pactffi_string_delete` + function once it is no longer required. + + [Rust + `pactffi_matches_json_value`](https://docs.rs/pact_ffi/0.5.4/pact_ffi/?search=pactffi_matches_json_value) + + * matching_rule - pointer to a matching rule + * expected_value - value we expect to get as a NULL terminated string + * actual_value - value to match as a NULL terminated string + * cascaded - if the matching rule has been cascaded from a parent. 0 == + false, 1 == true + + # Safety + + The matching rule pointer must be a valid pointer, and the value parameters + must be valid pointers to a NULL terminated strings. + """ + raise NotImplementedError diff --git a/pact-python-ffi/src/pact_ffi/__version__.py b/pact-python-ffi/src/pact_ffi/__version__.py new file mode 100644 index 000000000..bb75cdc87 --- /dev/null +++ b/pact-python-ffi/src/pact_ffi/__version__.py @@ -0,0 +1,10 @@ +"""Version information for pact-python-ffi.""" + +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("pact-python-ffi") +except PackageNotFoundError: + __version__ = "unknown" + +__version_tuple__ = tuple(int(x) for x in __version__.split(".") if x.isdigit()) diff --git a/pact-python-ffi/src/pact_ffi/ffi.pyi b/pact-python-ffi/src/pact_ffi/ffi.pyi new file mode 100644 index 000000000..897259dca --- /dev/null +++ b/pact-python-ffi/src/pact_ffi/ffi.pyi @@ -0,0 +1,6 @@ +import ctypes + +import cffi + +lib: ctypes.CDLL +ffi: cffi.FFI diff --git a/pact-python-ffi/src/pact_ffi/py.typed b/pact-python-ffi/src/pact_ffi/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/pact-python-ffi/tests/.ruff.toml b/pact-python-ffi/tests/.ruff.toml new file mode 100644 index 000000000..ee08a6db7 --- /dev/null +++ b/pact-python-ffi/tests/.ruff.toml @@ -0,0 +1,17 @@ +#:schema https://www.schemastore.org/ruff.json + +extend = "../pyproject.toml" + +# We have a number of helper files which contain assertions/magic values, etc. + +[lint] +ignore = [ + "D102", # Require docstring in public methods + "D103", # Require docstring in public function + "D104", # Require docstring in public package + "INP001", # Forbid implicit namespaces + "PLR2004", # Forbid magic values + "RUF018", # Forbid assignment in assertions + "S101", # Forbid assert statements + "TID252", # Require absolute imports +] diff --git a/pact-python-ffi/tests/test_init.py b/pact-python-ffi/tests/test_init.py new file mode 100644 index 000000000..efd6863fb --- /dev/null +++ b/pact-python-ffi/tests/test_init.py @@ -0,0 +1,261 @@ +""" +Test the FFI interface. + +Note that these tests are not intended to be exhaustive, as the full +functionality should be tested through the core Pact Python library. + +Instead, these tests fall under two broad categories: + +- Tests that ensure the FFI interface is working correctly. +- Tests that ensure some of the thin wrappers around the FFI interface + are functioning as expected. +""" + +from __future__ import annotations + +import re + +import pytest + +import pact_ffi +from pact_ffi.ffi import lib + + +def test_version() -> None: + assert isinstance(pact_ffi.version(), str) + assert len(pact_ffi.version()) > 0 + assert pact_ffi.version().count(".") == 2 + + +def test_string_result_ok() -> None: + result = pact_ffi.StringResult(lib.pactffi_generate_datetime_string(b"yyyy")) + assert result.is_ok + assert not result.is_failed + assert re.match(r"^\d{4}$", result.text) + assert str(result) == result.text + assert repr(result) == f"" + result.raise_exception() + + +def test_string_result_failed() -> None: + result = pact_ffi.StringResult(lib.pactffi_generate_datetime_string(b"t")) + assert not result.is_ok + assert result.is_failed + assert result.text.startswith("Error parsing") + with pytest.raises(RuntimeError): + result.raise_exception() + + +def test_datetime_valid() -> None: + pact_ffi.validate_datetime("2023-01-01", "yyyy-MM-dd") + + +def test_datetime_invalid() -> None: + with pytest.raises(ValueError, match=r"Invalid datetime value.*"): + pact_ffi.validate_datetime("01/01/2023", "yyyy-MM-dd") + + +def test_get_error_message() -> None: + # The first bit makes sure that an error is generated. + invalid_utf8 = b"\xc3\x28" + ret: int = lib.pactffi_validate_datetime(invalid_utf8, invalid_utf8) + assert ret == 2 + assert pact_ffi.get_error_message() == "error parsing value as UTF-8" + + +def test_owned_string() -> None: + string = pact_ffi.get_tls_ca_certificate() + assert isinstance(string, str) + assert len(string) > 0 + assert str(string) == string + assert repr(string).startswith("") + assert string.startswith("-----BEGIN CERTIFICATE-----") + assert string.endswith( + ( + "-----END CERTIFICATE-----\n", + "-----END CERTIFICATE-----\r\n", + ), + ) + + +class TestInteractionIteration: + """ + Test interaction iteration functionality. + """ + + @pytest.fixture + def pact(self) -> pact_ffi.PactHandle: + """Create a V4 pact for testing.""" + pact = pact_ffi.new_pact("consumer", "provider") + pact_ffi.with_specification(pact, pact_ffi.PactSpecification.V4) + return pact + + def test_interaction_iterator_repr(self, pact: pact_ffi.PactHandle) -> None: + iterator = pact_ffi.pact_model_interaction_iterator(pact.pointer()) + assert str(iterator) == "PactInteractionIterator" + assert repr(iterator).startswith("PactInteractionIterator(") + + def test_http_iterator_repr(self, pact: pact_ffi.PactHandle) -> None: + iterator = pact_ffi.pact_handle_get_sync_http_iter(pact) + assert str(iterator) == "PactSyncHttpIterator" + assert repr(iterator).startswith("PactSyncHttpIterator(") + + def test_async_message_iterator_repr(self, pact: pact_ffi.PactHandle) -> None: + iterator = pact_ffi.pact_handle_get_async_message_iter(pact) + assert str(iterator) == "PactAsyncMessageIterator" + assert repr(iterator).startswith("PactAsyncMessageIterator(") + + def test_sync_message_iterator_repr(self, pact: pact_ffi.PactHandle) -> None: + iterator = pact_ffi.pact_handle_get_sync_message_iter(pact) + assert str(iterator) == "PactSyncMessageIterator" + assert repr(iterator).startswith("PactSyncMessageIterator(") + + def test_empty_iterators(self, pact: pact_ffi.PactHandle) -> None: + inter_iter = pact_ffi.pact_model_interaction_iterator(pact.pointer()) + assert sum(1 for _ in inter_iter) == 0 + + http_iterator = pact_ffi.pact_handle_get_sync_http_iter(pact) + assert sum(1 for _ in http_iterator) == 0 + + async_iterator = pact_ffi.pact_handle_get_async_message_iter(pact) + assert sum(1 for _ in async_iterator) == 0 + + sync_iterator = pact_ffi.pact_handle_get_sync_message_iter(pact) + assert sum(1 for _ in sync_iterator) == 0 + + def test_iterators(self, pact: pact_ffi.PactHandle) -> None: + pact_ffi.new_interaction(pact, "http") + pact_ffi.new_message_interaction(pact, "async") + pact_ffi.new_sync_message_interaction(pact, "sync") + + # Test each iterator type + interaction_iter = pact_ffi.pact_model_interaction_iterator(pact.pointer()) + assert sum(1 for _ in interaction_iter) == 3 + assert sum(1 for _ in interaction_iter) == 0 # exhausted + + http_iter = pact_ffi.pact_handle_get_sync_http_iter(pact) + assert sum(1 for _ in http_iter) == 1 + assert sum(1 for _ in http_iter) == 0 # exhausted + + async_iter = pact_ffi.pact_handle_get_async_message_iter(pact) + assert sum(1 for _ in async_iter) == 1 + assert sum(1 for _ in async_iter) == 0 # exhausted + + sync_iter = pact_ffi.pact_handle_get_sync_message_iter(pact) + assert sum(1 for _ in sync_iter) == 1 + assert sum(1 for _ in sync_iter) == 0 # exhausted + + def test_iterator_types(self, pact: pact_ffi.PactHandle) -> None: + pact_ffi.new_interaction(pact, "http") + pact_ffi.new_message_interaction(pact, "async") + pact_ffi.new_sync_message_interaction(pact, "sync") + + interaction_iter = pact_ffi.pact_model_interaction_iterator(pact.pointer()) + assert all( + isinstance(interaction, pact_ffi.PactInteraction) + for interaction in interaction_iter + ) + + http_iter = pact_ffi.pact_handle_get_sync_http_iter(pact) + assert all( + isinstance(interaction, pact_ffi.SynchronousHttp) + for interaction in http_iter + ) + + async_iter = pact_ffi.pact_handle_get_async_message_iter(pact) + assert all( + isinstance(interaction, pact_ffi.AsynchronousMessage) + for interaction in async_iter + ) + + sync_iter = pact_ffi.pact_handle_get_sync_message_iter(pact) + assert all( + isinstance(interaction, pact_ffi.SynchronousMessage) + for interaction in sync_iter + ) + + +class TestPactModelHandle: + """ + Test basic Pact model pointer handling. + """ + + @pytest.fixture + def pact(self) -> pact_ffi.PactHandle: + """Create a V4 pact for testing.""" + pact = pact_ffi.new_pact("consumer", "provider") + pact_ffi.with_specification(pact, pact_ffi.PactSpecification.V4) + return pact + + def test_pact_handle_repr(self, pact: pact_ffi.PactHandle) -> None: + assert str(pact).startswith("PactHandle(") + assert repr(pact).startswith("PactHandle(") + + def test_pact_repr(self, pact: pact_ffi.PactHandle) -> None: + pact_model = pact_ffi.pact_handle_to_pointer(pact) + + assert isinstance(pact_model, pact_ffi.Pact) + assert str(pact_model) == "Pact" + assert repr(pact_model).startswith("Pact(") + + +class TestPactInteractionCasting: + """ + Test casting interactions to specific subtypes via iterators. + """ + + @pytest.fixture + def pact(self) -> pact_ffi.PactHandle: + """Create a V4 pact for testing.""" + pact = pact_ffi.new_pact("consumer", "provider") + pact_ffi.with_specification(pact, pact_ffi.PactSpecification.V4) + return pact + + def test_synchronous_http_casting(self, pact: pact_ffi.PactHandle) -> None: + """Test SynchronousHttp interaction casting and representation.""" + pact_ffi.new_interaction(pact, "http") + + # Test HTTP iterator yields SynchronousHttp + interaction_iter = pact_ffi.pact_model_interaction_iterator(pact.pointer()) + interaction = next(interaction_iter) + assert isinstance(interaction, pact_ffi.PactInteraction) + http = interaction.as_synchronous_http() + assert isinstance(http, pact_ffi.SynchronousHttp) + + with pytest.raises(TypeError): + interaction.as_asynchronous_message() + with pytest.raises(TypeError): + interaction.as_synchronous_message() + + def test_asynchronous_message_casting(self, pact: pact_ffi.PactHandle) -> None: + """Test AsynchronousMessage interaction casting and representation.""" + pact_ffi.new_message_interaction(pact, "async") + + # Test async message iterator yields AsynchronousMessage + interaction_iter = pact_ffi.pact_model_interaction_iterator(pact.pointer()) + interaction = next(interaction_iter) + assert isinstance(interaction, pact_ffi.PactInteraction) + async_msg = interaction.as_asynchronous_message() + assert isinstance(async_msg, pact_ffi.AsynchronousMessage) + + with pytest.raises(TypeError): + interaction.as_synchronous_http() + with pytest.raises(TypeError): + interaction.as_synchronous_message() + + def test_synchronous_message_casting(self, pact: pact_ffi.PactHandle) -> None: + """Test SynchronousMessage interaction casting and representation.""" + pact_ffi.new_sync_message_interaction(pact, "sync") + + # Test sync message iterator yields SynchronousMessage + interaction_iter = pact_ffi.pact_model_interaction_iterator(pact.pointer()) + interaction = next(interaction_iter) + assert isinstance(interaction, pact_ffi.PactInteraction) + sync_msg = interaction.as_synchronous_message() + assert isinstance(sync_msg, pact_ffi.SynchronousMessage) + + with pytest.raises(TypeError): + interaction.as_synchronous_http() + with pytest.raises(TypeError): + interaction.as_asynchronous_message() diff --git a/pact/__init__.py b/pact/__init__.py deleted file mode 100644 index e81016348..000000000 --- a/pact/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Python methods for interactive with a Pact Mock Service.""" -from .broker import Broker -from .consumer import Consumer -from .matchers import EachLike, Like, SomethingLike, Term, Format -from .message_consumer import MessageConsumer -from .message_pact import MessagePact -from .message_provider import MessageProvider -from .pact import Pact -from .provider import Provider -from .verifier import Verifier - -from .__version__ import __version__ # noqa: F401 - -__all__ = ('Broker', 'Consumer', 'EachLike', 'Like', - 'MessageConsumer', 'MessagePact', 'MessageProvider', - 'Pact', 'Provider', 'SomethingLike', 'Term', 'Format', 'Verifier') diff --git a/pact/__version__.py b/pact/__version__.py deleted file mode 100644 index 86d36b05e..000000000 --- a/pact/__version__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Pact version info.""" - -__version__ = '1.7.0' diff --git a/pact/constants.py b/pact/constants.py deleted file mode 100644 index 9c6f7b117..000000000 --- a/pact/constants.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Constant values for the pact-python package.""" -import os -from os.path import join, dirname, normpath - - -def broker_client_exe(): - """Get the appropriate executable name for this platform.""" - if os.name == 'nt': - return 'pact-broker.bat' - else: - return 'pact-broker' - - -def message_exe(): - """Get the appropriate executable name for this platform.""" - if os.name == 'nt': - return 'pact-message.bat' - else: - return 'pact-message' - - -def mock_service_exe(): - """Get the appropriate executable name for this platform.""" - if os.name == 'nt': - return 'pact-mock-service.bat' - else: - return 'pact-mock-service' - - -def provider_verifier_exe(): - """Get the appropriate provider executable name for this platform.""" - if os.name == 'nt': - return 'pact-provider-verifier.bat' - else: - return 'pact-provider-verifier' - - -BROKER_CLIENT_PATH = normpath(join( - dirname(__file__), 'bin', 'pact', 'bin', broker_client_exe())) - -MESSAGE_PATH = normpath(join( - dirname(__file__), 'bin', 'pact', 'bin', message_exe())) - -MOCK_SERVICE_PATH = normpath(join( - dirname(__file__), 'bin', 'pact', 'bin', mock_service_exe())) - -VERIFIER_PATH = normpath(join( - dirname(__file__), 'bin', 'pact', 'bin', provider_verifier_exe())) diff --git a/prek.toml b/prek.toml new file mode 100644 index 000000000..9f93ffdf4 --- /dev/null +++ b/prek.toml @@ -0,0 +1,91 @@ +#:schema https://raw.githubusercontent.com/j178/prek/refs/heads/master/prek.schema.json +#:tombi toml-version = "v1.1.0" + +[[repos]] +repo = "https://github.com/pre-commit/pre-commit-hooks" +rev = "v6.0.0" +hooks = [ + { id = "check-added-large-files" }, + { id = "check-case-conflict" }, + { id = "check-executables-have-shebangs" }, + { id = "check-illegal-windows-names" }, + { id = "check-merge-conflict" }, + { id = "check-shebang-scripts-are-executable" }, + { id = "check-symlinks" }, + { id = "check-vcs-permalinks" }, + { id = "destroyed-symlinks" }, + { id = "end-of-file-fixer" }, + { id = "fix-byte-order-marker" }, + { id = "mixed-line-ending" }, + { id = "trailing-whitespace" }, +] + +# YAML formatting +[[repos]] +repo = "https://github.com/lyz-code/yamlfix/" +rev = "1.19.1" +hooks = [{ id = "yamlfix" }] + +[[repos]] +repo = "https://gitlab.com/bmares/check-json5" +rev = "v1.0.1" +hooks = [ + # As above, this only checks for valid JSON files. This implementation + # allows for comments within JSON files. + { id = "check-json5" }, +] + +[[repos]] +repo = "https://github.com/biomejs/pre-commit" +rev = "v2.4.12" +hooks = [{ id = "biome-check" }] + +[[repos]] +repo = "https://github.com/astral-sh/ruff-pre-commit" +rev = "v0.15.11" + + [[repos.hooks]] + id = "ruff-check" + exclude = '(src/pact|tests|examples)/v2/.*\.pyi?' + args = ["--fix", "--exit-non-zero-on-fix"] + + [[repos.hooks]] + id = "ruff-format" + exclude = '(src/pact|tests|examples)/v2/.*\.pyi?' + +[[repos]] +repo = "https://github.com/crate-ci/committed" +rev = "v1.1.11" +hooks = [{ id = "committed" }] + +[[repos]] +repo = "https://github.com/DavidAnson/markdownlint-cli2" +rev = "v0.22.0" +hooks = [{ id = "markdownlint-cli2" }] + +[[repos]] +repo = "https://github.com/crate-ci/typos" +rev = "v1.45.1" + + [[repos.hooks]] + id = "typos" + exclude = 'mascot\.svg' + +[[repos]] +repo = "https://github.com/tombi-toml/tombi-pre-commit" +rev = "v0.9.20" +hooks = [{ id = "tombi-format" }, { id = "tombi-lint" }] + +[[repos]] +repo = "local" + + [[repos.hooks]] + # Mypy is difficult to run pre-commit's isolated environment as it needs + # to be able to find dependencies. + id = "mypy" + name = "mypy" + entry = "hatch run mypy" + language = "system" + types = ["python"] + exclude = '(src/pact|tests|examples)/v2/.*\.pyi?' + stages = ["pre-push"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..31c090a6c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,345 @@ +#:schema https://www.schemastore.org/pyproject.json + +[project] +name = "pact-python" +version = "3.4.0" +description = "Tool for creating and verifying consumer-driven contracts using the Pact framework." +readme = "README.md" +license = { file = "LICENSE" } +keywords = ["contract-testing", "pact", "testing"] + +authors = [ + { name = "Joshua Ellis", email = "josh@jpellis.me" }, + { name = "Matthew Balvanz", email = "matthew.balvanz@workiva.com" }, +] +maintainers = [{ name = "Joshua Ellis", email = "josh@jpellis.me" }] + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Testing", +] + +requires-python = ">=3.10" + +# Dependencies of Pact Python should be specified using the broadest range +# compatible version unless: +# +# - A specific feature is required in a new minor release +# - A minor version address vulnerability which directly impacts Pact Python +dependencies = [ + # Pact dependencies + "pact-python-ffi~=0.5.0", + "typing-extensions~=4.0 ; python_version < '3.13'", + # External dependencies + "yarl~=1.0", +] + + [project.urls] + changelog = "https://github.com/pact-foundation/pact-python/blob/main/CHANGELOG.md" + documentation = "https://pact-foundation.github.io/pact-python/" + homepage = "https://pact.io" + issues = "https://github.com/pact-foundation/pact-python/issues" + source = "https://github.com/pact-foundation/pact-python" + + [project.scripts] + pact-verifier = "pact.v2.cli.verify:main" + + [project.optional-dependencies] + # Dependencies required for v2 only + compat-v2 = [ + # Pact dependencies + "pact-python-cli~=2.5", + # External dependencies + "click~=8.0", + "psutil~=7.0", + "requests~=2.0", + "six~=1.0", + ] + +[dependency-groups] +# Linting and formatting tools use a more narrow specification to ensure +# developper consistency. All other dependencies are as above. +dev = [ + "ruff==0.15.13", + { include-group = "docs" }, + { include-group = "example" }, + { include-group = "example-v2" }, + { include-group = "test" }, + { include-group = "test-v2" }, + { include-group = "types" }, +] + +docs = [ + "griffe-generics==1.0.13", + "griffe-inherited-method-crossrefs==0.0.1.4", + "griffe-pydantic==1.3.1", + "griffe-warnings-deprecated==1.1.1", + "mkdocs==1.6.1", + "mkdocs-gen-files==0.6.1", + "mkdocs-github-admonitions-plugin==0.1.1", + "mkdocs-literate-nav==0.6.3", + "mkdocs-llmstxt==0.5.0", + "mkdocs-material[recommended,git,imaging]==9.7.6", + "mkdocs-section-index==0.3.12", + "mkdocstrings[python]==1.0.4", + "pathspec==1.1.1", +] +example = [ + "fastapi~=0.0", + "flask[async]~=3.0", + "grpcio~=1.0", + "protobuf~=7.34", + "pydantic~=2.0", + "pytest~=9.0", + "pytest-cov~=7.0", + "pytest-rerunfailures~=16.0", + "python-multipart~=0.0", + "testcontainers~=4.0", + "uvicorn[standard]~=0.0", +] +test = [ + "aiohttp~=3.0", + "flask~=3.0", + "pact-python-cli", + "pytest~=9.0", + "pytest-asyncio~=1.0", + "pytest-bdd~=8.0", + "pytest-cov~=7.0", + "pytest-rerunfailures~=16.0", + "requests~=2.0", + "testcontainers~=4.0", +] +types = [ + "mypy==2.1.0", + "types-grpcio~=1.0", + "types-protobuf~=7.34", + "types-requests~=2.0", + "typing-extensions~=4.0", # For Python 3.10 support +] + +# Dependencies for v2 example and test environments +example-v2 = [ + "fastapi~=0.0", + "flask[async]~=3.0", + "pytest~=9.0", + "pytest-cov~=7.0", + "pytest-rerunfailures~=16.0", + "testcontainers~=4.0", + "uvicorn[standard]~=0.0", +] +test-v2 = [ + "fastapi~=0.0", + "httpx~=0.0", + "mock~=5.0", + "pytest~=9.0", + "pytest-cov~=7.0", + "pytest-rerunfailures~=16.0", + "uvicorn[standard]~=0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool] + [tool.coverage] + [tool.coverage.paths] + pact = ["/src/pact"] + tests = ["/examples", "/tests"] + + [tool.coverage.report] + exclude_lines = [ + "@(abc\\.)?abstractmethod", # Ignore abstract methods + "if TYPE_CHECKING:", # Ignore typing + "if __name__ == .__main__.:", # Ignore non-runnable code + "raise NotImplementedError", # Ignore defensive assertions + ] + + [tool.hatch] + [tool.hatch.build] + packages = ["src/pact"] + + [tool.hatch.envs] + [tool.hatch.envs.default] + installer = "uv" + path = ".venv" + extra-dependencies = ["hatchling", "packaging"] + dependency-groups = ["dev"] + + [tool.hatch.envs.default.scripts] + all = ["example", "format", "lint", "test", "typecheck"] + docs = "mkdocs serve {args}" + docs-build = "mkdocs build {args}" + example = "pytest --ignore=examples/v2 examples/ {args}" + format = "ruff format {args}" + lint = "ruff check --output-format=full --show-fixes {args}" + test = "pytest --ignore=tests/v2 tests/ {args}" + typecheck = ["typecheck-examples", "typecheck-src", "typecheck-tests"] + typecheck-examples = "mypy examples/ {args}" + typecheck-src = "mypy src/ {args}" + typecheck-tests = "mypy tests/ {args}" + + # Test environment for running unit tests. + [tool.hatch.envs.test] + installer = "uv" + path = ".venv/test" + dependency-groups = ["test"] + + [[tool.hatch.envs.test.matrix]] + python = ["3.10", "3.11", "3.12", "3.13", "3.14"] + + [tool.hatch.envs.example] + installer = "uv" + path = ".venv/example" + dependency-groups = ["example"] + + [[tool.hatch.envs.example.matrix]] + python = ["3.10", "3.11", "3.12", "3.13", "3.14"] + + [tool.hatch.envs.v2-test] + features = ["compat-v2"] + installer = "uv" + path = ".venv/v2-test" + pre-install-commands = ["uv pip install --group test-v2 -e ."] + + [tool.hatch.envs.v2-test.scripts] + all = ["test"] + test = "pytest tests/v2 {args}" + + [[tool.hatch.envs.v2-test.matrix]] + python = ["3.10", "3.11", "3.12", "3.13", "3.14"] + + [tool.hatch.envs.v2-example] + features = ["compat-v2"] + installer = "uv" + path = ".venv/v2-example" + pre-install-commands = ["uv pip install --group example-v2 -e ."] + + [tool.hatch.envs.v2-example.scripts] + all = ["example"] + example = "pytest examples/v2 {args}" + + [[tool.hatch.envs.v2-example.matrix]] + python = ["3.10", "3.11", "3.12", "3.13", "3.14"] + + [tool.mypy] + exclude = """(?x)^( + (src/pact|tests|examples)/v2/.*\\.pyi? + )$""" + + [tool.pytest] + addopts = [ + # Coverage options + "--cov-config=pyproject.toml", + "--cov-report=xml", + "--cov=pact", + "--import-mode=importlib", + # Reruns + "--reruns=5", + ] + + asyncio_default_fixture_loop_scope = "session" + + filterwarnings = [ + "ignore::DeprecationWarning:examples", + "ignore::DeprecationWarning:pact", + "ignore::DeprecationWarning:tests", + ] + + log_date_format = "%H:%M:%S" + log_format = "%(asctime)s.%(msecs)03d [%(levelname)-8s] %(name)s: %(message)s" + log_level = "NOTSET" + + markers = [ + # Marker for tests that require a container + "container", + + # Markers for the compatibility suite + "consumer", + "message", + "provider", + ] + + [tool.ruff] + extend-exclude = ["src/pact/v2/*", "tests/v2/*", "examples/v2/*"] + + [tool.ruff.lint] + select = ["ALL"] + + ignore = [ + "D200", # Require single line docstrings to be on one line. + "D203", # Require blank line before class docstring + "D212", # Multi-line docstring summary must start at the first line + "FIX002", # Forbid TODO in comments + "TD002", # Assign someone to 'TODO' comments + + # The following are disabled for compatibility with the formatter + "COM812", # enforce trailing commas + "ISC001", # require imports to be sorted + ] + + [tool.ruff.lint.pyupgrade] + keep-runtime-typing = true + + [tool.ruff.lint.pydocstyle] + convention = "google" + + [tool.ruff.lint.isort] + known-first-party = ["pact", "pact_cli", "pact_ffi"] + + [tool.ruff.lint.flake8-tidy-imports] + ban-relative-imports = "all" + + [tool.ruff.lint.per-file-ignores] + "test_*.py" = [ + "D103", # Require docstring in public function + "D104", # Require docstring in public package + "INP001", # Forbid implicit namespaces + "PLR2004", # Forbid magic values + "RUF018", # Forbid assignment in assertions + "S101", # Forbid assert statements + "TID252", # Require absolute imports + ] + + [tool.ruff.format] + docstring-code-format = true + preview = true + + [tool.typos] + [tool.typos.default] + extend-ignore-re = [ + # Ignore spelling on a specific line + "(?Rm)^.*(#|//)\\s*spellchecker:disable-line$", + # Ignore spelling in a specific block + "(?s)(#|//)\\s*spellchecker:off.*?\\n\\s*(#|//)\\s*spellchecker:on", + ] + + [tool.typos.files] + extend-exclude = ["*.svg"] + + [tool.uv] + [tool.uv.sources] + pact-python-cli = { workspace = true } + pact-python-ffi = { workspace = true } + + [tool.uv.workspace] + members = ["pact-python-cli", "pact-python-ffi"] + + [tool.yamlfix] + line_length = 100 + section_whitelines = 1 + sequence_style = "block_style" + whitelines = 1 diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index c7d62261e..000000000 --- a/requirements_dev.txt +++ /dev/null @@ -1,26 +0,0 @@ -Click<=8.0.4; python_version < '3.7' -Click>=8.1.3; python_version >= '3.7' -coverage==5.4 -Flask==2.0.3; python_version < '3.7' -Flask==2.2.2; python_version >= '3.7' -configparser==3.5.0 -flake8==5.0.4 -mock==3.0.5 -psutil==5.9.4 -pycodestyle==2.9.0 -pydocstyle==4.0.1 -tox==3.27.1 -pytest==7.0.1; python_version < '3.7' -pytest==7.1.3; python_version >= '3.7' -pytest-cov==2.11.1 -requests==2.27.1; python_version < '3.7' -requests>=2.28.0; python_version >= '3.7' -urllib3>=1.26.12 -uvicorn==0.16.0; python_version < '3.7' -uvicorn>=0.19.0; python_version >= '3.7' -wheel==0.37.1; python_version < '3.7' -wheel==0.40.0; python_version >= '3.7' -markupsafe==2.0.1; python_version < '3.7' -markupsafe==2.1.2; python_version >= '3.7' -httpx==0.22.0; python_version < '3.7' -httpx==0.23.3; python_version >= '3.7' diff --git a/script/commit_message.py b/script/commit_message.py deleted file mode 100755 index dec30d1ee..000000000 --- a/script/commit_message.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python -import re -import sys -import subprocess - -examples = """+ 61c8ca9 fix: navbar not responsive on mobile -+ 479c48b test: prepared test cases for user authentication -+ a992020 chore: moved to semantic versioning -+ b818120 fix: button click even handler firing twice -+ c6e9a97 fix: login page css -+ dfdc715 feat(auth): added social login using twitter -""" - - -def main(): - - cmd_tag = "git describe --abbrev=0" - tag = subprocess.check_output(cmd_tag, - shell=True).decode("utf-8").split('\n')[0] - - cmd = "git log --pretty=format:'%s' {}..HEAD".format(tag) - commits = subprocess.check_output(cmd, shell=True) - commits = commits.decode("utf-8").split('\n') - for commit in commits: - - pattern = r'((build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert)(\([\w\-]+\))?:\s.*)|((Merge|Fixed)(\([\w\-]+\))?\s.*)' # noqa - m = re.match(pattern, commit) - if m is None: - print("\nError with git message '{}' style".format(commit)) - print("\nPlease change commit message to the conventional format and try to commit again. Examples:") # noqa - - print("\n" + examples) - sys.exit(1) - - print("Commit messages valid") - - -if __name__ == "__main__": - main() diff --git a/script/create-pr-to-update-pact-ruby-standalone.sh b/script/create-pr-to-update-pact-ruby-standalone.sh deleted file mode 100755 index 5191dae21..000000000 --- a/script/create-pr-to-update-pact-ruby-standalone.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -e - -: "${1?Please supply the pact-ruby-standalone version to upgrade to}" - -STANDALONE_VERSION=$1 -TYPE=${2:-feat} -DASHERISED_VERSION=$(echo "${STANDALONE_VERSION}" | sed 's/\./\-/g') -BRANCH_NAME="chore/upgrade-to-pact-ruby-standalone-${DASHERISED_VERSION}" - -git checkout master -git checkout setup.py -git pull origin master - -git checkout -b ${BRANCH_NAME} - -cat setup.py | sed "s/PACT_STANDALONE_VERSION =.*/PACT_STANDALONE_VERSION = '${STANDALONE_VERSION}'/" > tmp-setup -mv tmp-setup setup.py - -git add setup.py -git commit -m "${TYPE}: update standalone to ${STANDALONE_VERSION}" -git push --set-upstream origin ${BRANCH_NAME} - -# hub pull-request --browse --message "${TYPE}: update standalone to ${STANDALONE_VERSION}" -gh pr create -w --title "${TYPE}: update standalone to ${STANDALONE_VERSION}" -git checkout master diff --git a/script/release_prep.sh b/script/release_prep.sh deleted file mode 100755 index 336231cfd..000000000 --- a/script/release_prep.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -VERSION=$1 - -if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]*$ ]]; then - echo "Updating version $VERSION." -else - echo "Invalid version number $VERSION" - exit 1; -fi - -TAG_NAME="v$VERSION" -LAST_TAG=`git describe --abbrev=0` - - -cat pact/__version__.py | sed "s/__version__ = .*/__version__ = '${VERSION}'/" > tmp-version -mv tmp-version pact/__version__.py - -echo "Releasing $TAG_NAME" - -echo -e "`git log --pretty=format:' * %h - %s (%an, %ad)' $LAST_TAG..HEAD`\n$(cat CHANGELOG.md)" > CHANGELOG.md -echo -e "### $VERSION\n$(cat CHANGELOG.md)" > CHANGELOG.md - -echo "Appended Changelog to $VERSION" - -git add CHANGELOG.md pact/__version__.py -git commit -m "chore: Releasing version $VERSION" - -git tag -a "$TAG_NAME" -m "Releasing version $VERSION" && git push origin master --tags - - diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 000000000..9d152b271 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,718 @@ +#!/usr/bin/env python3 +# ruff: noqa: S603, S607 +"""Release management script for pact-python packages. + +Usage: + python scripts/release.py prepare core [--dry-run] + python scripts/release.py prepare ffi [--dry-run] + python scripts/release.py prepare cli [--dry-run] + + python scripts/release.py tag core + python scripts/release.py tag ffi + python scripts/release.py tag cli +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import tomllib + +ROOT = Path(__file__).parent.parent +logger = logging.getLogger(__name__) + + +@dataclass +class PullRequest: + """A GitHub pull request as returned by `gh pr list`.""" + + number: int + """GitHub PR number.""" + + head_ref_name: str + """The head branch name (JSON field `headRefName`).""" + + +@dataclass +class Package: + """Configuration for a releasable package in the monorepo.""" + + name: str + """PyPI package name, e.g. `"pact-python"`.""" + + key: str + """Short identifier used as the CLI argument and as the `PACKAGES` lookup key.""" + + directory: Path + """Absolute path to the package root (where `pyproject.toml` lives).""" + + tag_prefix: str + """Git tag prefix, e.g. `"pact-python/"` → tag `"pact-python/3.2.2"`.""" + + upstream_repo: str | None + """GitHub repo to track for the upstream version (`owner/repo`). + `None` for the core package, which derives its version from git history + via git cliff instead of following an external release.""" + + upstream_tag_prefix: str | None + """Prefix used to identify and strip upstream release tags. + + Releases whose `tagName` does not start with this prefix are ignored, and + the prefix is stripped to produce the bare version string. Examples: + + - `"libpact_ffi-v"` for `pact-foundation/pact-reference` (only libpact_ffi + releases, not mock_server / verifier / etc.) + - `"v"` for `pact-foundation/pact-standalone` + + `None` when `upstream_repo` is `None` (i.e., the core package). + """ + + release_branch: str + """Fixed branch name for release PRs, e.g. `"release/pact-python"`.""" + + def read_version(self) -> str: + """Read the static version from the package's pyproject.toml.""" + with (self.directory / "pyproject.toml").open("rb") as f: + data = tomllib.load(f) + return data["project"]["version"] + + def write_version(self, version: str) -> None: + """Update the version field in the package's pyproject.toml. + + Uses a regex substitution rather than TOML round-tripping so that + comments, formatting, and key ordering in pyproject.toml are preserved. + """ + toml_path = self.directory / "pyproject.toml" + content = toml_path.read_text() + new_content = re.sub( + r'^(version\s*=\s*")[^"]*(")', + rf"\g<1>{version}\g<2>", + content, + count=1, + flags=re.MULTILINE, + ) + if new_content == content: + msg = f"Could not find version field to update in {toml_path}" + raise ValueError(msg) + logger.debug("Writing version %s to %s", version, toml_path) + toml_path.write_text(new_content) + + def compute_tag_name(self, version: str) -> str: + """Return the full git tag name for a package version.""" + return f"{self.tag_prefix}{version}" + + def fetch_upstream_version(self) -> str: + """Fetch the latest upstream release version (without prefix) via gh CLI. + + Uses `gh release list` filtered by + [`upstream_tag_prefix`][scripts.release.Package.upstream_tag_prefix] + to find the most recent matching release, then strips the prefix to + return the bare version string. + + Returns: + The upstream version string without any prefix, e.g. `"0.4.28"`. + + Raises: + ValueError: If this package has no upstream repo or tag prefix configured. + """ + if self.upstream_repo is None or self.upstream_tag_prefix is None: + msg = f"Package {self.name!r} has no upstream repo configured" + raise ValueError(msg) + + prefix = self.upstream_tag_prefix + logger.debug( + "Fetching upstream version from %s (tag prefix: %r)", + self.upstream_repo, + prefix, + ) + jq_filter = ( + f'[.[] | select(.tagName | startswith("{prefix}"))] | first | .tagName' + ) + result = subprocess.check_output( + [ + "gh", + "release", + "list", + "--repo", + self.upstream_repo, + "--json", + "tagName", + "--jq", + jq_filter, + ], + text=True, + ) + tag = result.strip() + if not tag or tag == "null": + msg = f"No release with prefix {prefix!r} found in {self.upstream_repo!r}" + raise ValueError(msg) + version = tag.removeprefix(prefix) + logger.debug("Upstream tag: %s → version: %s", tag, version) + return version + + def compute_semver_version(self) -> str | None: + """Return the next semver via git cliff, or None. + + Returns `None` when there are no unreleased commits that would produce a + version bump (git cliff exits non-zero or returns empty output). + + Returns: + The next version string (e.g. `"3.2.2"`), or `None` if no bump is + needed. + """ + logger.debug("Running git cliff --bumped-version in %s", self.directory) + result = subprocess.run( + ["git", "cliff", "--bumped-version"], + capture_output=True, + text=True, + check=False, + cwd=self.directory, + ) + if result.returncode != 0 or not result.stdout.strip(): + logger.debug("git cliff: no version bump needed") + return None + raw = result.stdout.strip() + # git cliff outputs the full tag name (e.g. "pact-python/3.2.2"); strip prefix + version = strip_tag_prefix(raw, self.tag_prefix) + logger.debug("git cliff bumped version: %s", version) + return version + + def compute_next_version(self) -> str | None: + """Return the proposed next version string, or None if no release is needed. + + Dispatches to one of two strategies based on whether the package tracks + an upstream: + + - **Core** (`upstream_repo` is `None`): asks git cliff for the next + semver implied by unreleased conventional commits; returns `None` when + there is nothing to release. + - **FFI / CLI** (`upstream_repo` set): fetches the latest upstream GitHub + release and derives the 4-part version via `compute_wrapper_version`. + + Returns: + The next version string, or `None` if nothing has changed since the + last release. + """ + if self.upstream_repo is None: + logger.debug("Computing next version for %s via git cliff", self.name) + return self.compute_semver_version() + logger.debug( + "Computing next version for %s via upstream %s", + self.name, + self.upstream_repo, + ) + upstream = self.fetch_upstream_version() + current = self.read_version() + logger.debug("Current: %s Upstream: %s", current, upstream) + return compute_wrapper_version(upstream, current) + + def find_open_release_pr(self) -> PullRequest | None: + """Return the open release PR for this package, or None.""" + logger.debug( + "Looking for open release PR on branch %r", + self.release_branch, + ) + result = subprocess.check_output( + [ + "gh", + "pr", + "list", + "--head", + self.release_branch, + "--state", + "open", + "--json", + "number,headRefName", + "--jq", + "first", + ], + text=True, + ) + pr = parse_existing_pr(result) + if pr is not None: + logger.debug( + "Found existing PR #%d on branch %s", pr.number, pr.head_ref_name + ) + else: + logger.debug("No existing release PR found") + return pr + + def generate_changelog_body(self, version: str) -> str: + """ + Generate the changelog entry body for a proposed version using git cliff. + + Uses `--tag` to assign the version to unreleased commits and + `--strip header` to return only the entry body without the changelog + header. + + Args: + version: + The proposed next version string. + + Returns: + The rendered changelog body as a string. + """ + tag = self.compute_tag_name(version) + logger.debug("Generating changelog body for tag %s", tag) + return subprocess.check_output( + ["git", "cliff", "--tag", tag, "--unreleased", "--strip", "header"], + text=True, + cwd=self.directory, + ) + + def update_changelog_file(self, version: str) -> None: + """ + Prepend the changelog entry for unreleased commits to CHANGELOG.md. + + Uses `--prepend` rather than `--output` so that only the new entry is + inserted at the top of the file, leaving all previous entries (including + any manual edits) untouched. + + Args: + version: + The version to assign to the unreleased commits. + """ + tag = self.compute_tag_name(version) + logger.debug( + "Prepending changelog entry to CHANGELOG.md in %s for tag %s", + self.directory, + tag, + ) + subprocess.check_call( + ["git", "cliff", "--tag", tag, "--unreleased", "--prepend", "CHANGELOG.md"], + text=True, + cwd=self.directory, + ) + + def _configure_git_for_ci(self) -> None: + """Configure git identity and authenticate the remote for CI operations. + + Sets `user.name` and `user.email` only when they are not already + configured (preserving a developer's local git config when running + outside CI). If `GH_TOKEN` is set, embeds the token in the HTTPS + remote URL so that `git push` and `gh` operations authenticate without + a separate credential helper. + """ + logger.debug("Configuring git identity for CI") + if not subprocess.run( + ["git", "config", "--get", "user.name"], + text=True, + check=False, + capture_output=True, + cwd=ROOT, + ).stdout.strip(): + logger.debug("Setting git user.name to github-actions[bot]") + subprocess.check_call( + ["git", "config", "user.name", "github-actions[bot]"], + text=True, + cwd=ROOT, + ) + if not subprocess.run( + ["git", "config", "--get", "user.email"], + text=True, + check=False, + capture_output=True, + cwd=ROOT, + ).stdout.strip(): + logger.debug("Setting git user.email to github-actions[bot]@...") + subprocess.check_call( + [ + "git", + "config", + "user.email", + "github-actions[bot]@users.noreply.github.com", + ], + text=True, + cwd=ROOT, + ) + + def _push_release_branch( + self, + pr_title: str, + changelog: str, + existing_pr: PullRequest | None, + ) -> None: + """Create or update the fixed release branch, then create or update the PR. + + Resets the fixed release branch to the current HEAD (main), commits the + prepared files, force-pushes, and creates or updates the PR title and body. + Using a fixed branch name means no old PRs need to be closed when the + proposed version changes — the existing PR is simply updated in place. + + Args: + pr_title: + The PR title, e.g. `"chore(release): pact-python v3.2.2"`. + changelog: + The rendered changelog body to set as the PR description. + existing_pr: + The open [`PullRequest`][scripts.release.PullRequest] to update, + or `None` to create a new PR. + """ + branch = self.release_branch + pkg_files = [ + str(self.directory / "pyproject.toml"), + str(self.directory / "CHANGELOG.md"), + ] + if existing_pr is not None: + logger.debug( + "Updating existing release branch %s (PR #%d)", + branch, + existing_pr.number, + ) + else: + logger.debug("Creating new release branch %s", branch) + subprocess.check_call( + ["git", "checkout", "-B", branch, "origin/main"], + text=True, + cwd=ROOT, + ) + subprocess.check_call( + ["git", "add", *pkg_files], + text=True, + cwd=ROOT, + ) + subprocess.check_call( + ["git", "commit", "-m", pr_title], + text=True, + cwd=ROOT, + ) + subprocess.check_call( + ["git", "push", "--force", "origin", branch], + text=True, + cwd=ROOT, + ) + if existing_pr is not None: + subprocess.check_call( + [ + "gh", + "pr", + "edit", + str(existing_pr.number), + "--title", + pr_title, + "--body", + changelog, + ], + text=True, + ) + else: + subprocess.check_call( + [ + "gh", + "pr", + "create", + "--title", + pr_title, + "--body", + changelog, + "--base", + "main", + "--draft", + ], + text=True, + ) + + def prepare(self, *, dry_run: bool) -> None: + """Create or update the release PR for this package. + + Determines the next version, generates a changelog, writes the version + and changelog to disk, then (unless `dry_run` is set) creates or updates + the release PR on GitHub. The release branch is a fixed name + (`release_branch`) that is force-pushed on every run, so the same PR + is updated in place even when the proposed version changes. + + File writes (`pyproject.toml`, `CHANGELOG.md`) always happen so the + output can be inspected locally; they are easily reverted with + `git checkout`. Git branch operations (push, PR create/edit) are + skipped when `dry_run` is `True`. + + Args: + dry_run: + When `True`, write files to disk and print a summary of the + GitHub actions that *would* be taken, but do not push, create, + or update any branches or pull requests. + """ + version = self.compute_next_version() + if version is None: + logger.info("No version bump needed for %s. Nothing to do.", self.name) + return + + logger.info("Proposed next version for %s: %s", self.name, version) + changelog = self.generate_changelog_body(version) + pr_title = f"chore(release): {self.name} v{version}" + existing_pr = self.find_open_release_pr() + + # Write version and changelog to disk. These are always applied so the + # result can be inspected locally; `git checkout` reverts them cleanly. + logger.debug("Writing version and changelog to disk") + self.write_version(version) + self.update_changelog_file(version) + + if dry_run: + logger.info( + "\n--- Changelog for %s v%s ---\n%s", + self.name, + version, + changelog, + ) + if existing_pr is not None: + logger.info( + "[dry-run] Would update PR #%d title and body on branch %r.", + existing_pr.number, + self.release_branch, + ) + else: + logger.info( + "[dry-run] Would create PR %r on branch %r targeting main.", + pr_title, + self.release_branch, + ) + logger.info("[dry-run] Files written — use `git checkout` to revert.") + return + + try: + self._configure_git_for_ci() + self._push_release_branch(pr_title, changelog, existing_pr) + finally: + # Return to main so the local repo is left in a clean state, even on failure + logger.debug("Returning to main branch") + subprocess.check_call( + ["git", "checkout", "main"], + text=True, + cwd=ROOT, + ) + + logger.info("Release PR for %s v%s created/updated.", self.name, version) + + def tag(self, *, dry_run: bool = False) -> None: + """Create and push the release git tag for this package. + + Reads the version from `pyproject.toml` (the unconditional source of truth) + and pushes a tag of the form `{tag_prefix}{version}`. Idempotent: exits + cleanly with code 0 if the tag already exists, so the workflow can be + re-triggered safely after a transient failure. + + Args: + dry_run: + When `True`, print the tag that would be created without + pushing anything. + """ + logger.debug("Reading version from %s/pyproject.toml", self.directory) + version = self.read_version() + tag_name = self.compute_tag_name(version) + logger.debug("Computed tag name: %s", tag_name) + + # Fetch tags so the local repo reflects the remote state before checking + logger.debug("Fetching tags from origin") + subprocess.check_call( + ["git", "fetch", "--tags", "origin"], + cwd=ROOT, + ) + + # Check if the tag already exists + logger.debug("Checking whether tag %s already exists", tag_name) + result = subprocess.check_output( + ["git", "tag", "-l", tag_name], + text=True, + cwd=ROOT, + ) + if result.strip(): + logger.info("Tag %r already exists. Nothing to do.", tag_name) + sys.exit(0) + + if dry_run: + logger.info("[dry-run] Would create and push tag %r.", tag_name) + return + + logger.info("Creating tag %r...", tag_name) + subprocess.check_call( + ["git", "tag", tag_name], + text=True, + cwd=ROOT, + ) + logger.debug("Pushing tag %s to origin", tag_name) + subprocess.check_call( + ["git", "push", "origin", tag_name], + text=True, + cwd=ROOT, + ) + logger.info("Tag %r pushed.", tag_name) + + +# Each package uses a different versioning strategy: +# core — git cliff --bumped-version derives the next semver from commit history. +# ffi — tracks pact-foundation/pact-reference; version is {upstream}.{N}. +# cli — tracks pact-foundation/pact-standalone; version is {upstream}.{N}. +# The {N} suffix increments when the upstream semver is unchanged, resets to 0 +# on a new upstream release (see compute_wrapper_version). +PACKAGES: dict[str, Package] = { + "core": Package( + name="pact-python", + key="core", + directory=ROOT, + tag_prefix="pact-python/", + upstream_repo=None, + upstream_tag_prefix=None, + release_branch="release/pact-python", + ), + "ffi": Package( + name="pact-python-ffi", + key="ffi", + directory=ROOT / "pact-python-ffi", + tag_prefix="pact-python-ffi/", + upstream_repo="pact-foundation/pact-reference", + # Filter to libpact_ffi releases only — pact-reference hosts many crates + upstream_tag_prefix="libpact_ffi-v", + release_branch="release/pact-python-ffi", + ), + "cli": Package( + name="pact-python-cli", + key="cli", + directory=ROOT / "pact-python-cli", + tag_prefix="pact-python-cli/", + upstream_repo="pact-foundation/pact-standalone", + upstream_tag_prefix="v", + release_branch="release/pact-python-cli", + ), +} + + +def strip_tag_prefix(tag_or_version: str, prefix: str) -> str: + """Strip a tag prefix from a version string if present. + + Args: + tag_or_version: + A full tag name (e.g. `"pact-python/3.2.2"`) or a bare version + string (e.g. `"3.2.2"`). + prefix: + The prefix to remove (e.g. `"pact-python/"`). + + Returns: + The version string with the prefix removed, or the original string if + the prefix is not present. + """ + return tag_or_version.removeprefix(prefix) + + +def compute_wrapper_version(upstream_version: str, current_version: str) -> str: + """Compute the next 4-part version for wrapper packages. + + The version format is `{upstream_version}.{N}` where `N` is a per-packaging + suffix, and upstream version will (typically) be a semver-compatible + version. When the upstream semver portion matches the current version's + first three components the suffix is incremented; otherwise the suffix + resets to 0. + + Args: + upstream_version: + The latest upstream semver string, e.g. `"0.4.28"`. + current_version: + The current 4-part package version, e.g. `"0.4.28.2"`. + + Returns: + The next 4-part version string, e.g. `"0.4.28.3"` or `"0.4.29.0"`. + """ + current_parts = current_version.split(".") + current_upstream = ".".join(current_parts[:3]) + if upstream_version == current_upstream: + return f"{upstream_version}.{int(current_parts[3]) + 1}" + return f"{upstream_version}.0" + + +def parse_existing_pr(gh_output: str) -> PullRequest | None: + """Parse JSON output from `gh pr list` into a `PullRequest`, or None. + + Args: + gh_output: + Raw stdout from a `gh pr list --json` call. + + Returns: + A `PullRequest`, or `None` if the output is empty or the JSON + literal `"null"`. + """ + text = gh_output.strip() + if not text or text == "null": + return None + data = json.loads(text) + return PullRequest(number=data["number"], head_ref_name=data["headRefName"]) + + +def check_github_token() -> None: + """ + Ensure that a GitHub token is set for authenticated API access. + + In CI, the PAT is expected to be provided via the `GH_TOKEN` environment + variable. When running locally, if `GITHUB_TOKEN` is not already set, this + function will attempt to retrieve a token using the `gh` CLI tool (which may + be authenticated via `gh auth login`) and set it in the environment for + subsequent API calls. + + If no token can be found, a warning is logged and API requests may be + subject to stricter rate limits. + """ + if os.getenv("GITHUB_TOKEN"): + return + + if token := os.getenv("GH_TOKEN"): + logger.debug("Using GH_TOKEN from environment variable for authentication") + os.environ["GITHUB_TOKEN"] = token + return + + if "CI" not in os.environ: + logger.info("Generating a GitHub token for authenticated API access") + if token := subprocess.check_output( + ["gh", "auth", "token"], + text=True, + ).strip(): + os.environ["GITHUB_TOKEN"] = token + return + logger.warning("No GitHub token found. API requests may be rate-limited.") + + +def main() -> None: + """Entry point for the release management script.""" + parser = argparse.ArgumentParser( + description="Release management for pact-python packages" + ) + parser.add_argument("command", choices=["prepare", "tag"]) + parser.add_argument("package", choices=["core", "ffi", "cli"]) + parser.add_argument( + "--dry-run", + action="store_true", + help="Write files but skip all git/GitHub operations", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging to trace each step", + ) + args = parser.parse_args() + + level = logging.DEBUG if args.debug else logging.INFO + # Show the level prefix only in debug mode to keep normal output clean + fmt = "%(levelname)s: %(message)s" if args.debug else "%(message)s" + logging.basicConfig(level=level, format=fmt) + + pkg = PACKAGES[args.package] + + check_github_token() + + if args.command == "tag": + pkg.tag(dry_run=args.dry_run) + elif args.command == "prepare": + pkg.prepare(dry_run=args.dry_run) + else: + sys.stderr.write(f"Unknown command {args.command!r}\n") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 8f6688010..000000000 --- a/setup.cfg +++ /dev/null @@ -1,24 +0,0 @@ -[coverage:report] -exclude_lines = - if __name__ == .__main__.: - pragma: no cover - -[flake8] -ignore = E226,E302,E41,W503 -max-line-length = 160 -max-complexity = 12 -exclude = .git,build,dist,venv,.venv,.tox,.pytest_cache,.direnv - -[nosetests] -with-coverage=true -cover-package=pact -cover-branches=true -with-xunit=true -xunit-file=nosetests.xml - -[pydocstyle] -match-dir=[^(test|\.)].* - - -[tool:pytest] -norecursedirs=examples diff --git a/setup.py b/setup.py deleted file mode 100644 index 98d67657d..000000000 --- a/setup.py +++ /dev/null @@ -1,288 +0,0 @@ -"""pact-python PyPI Package.""" - -import os -import platform -import shutil -import sys -import tarfile - -from zipfile import ZipFile - -from setuptools import setup -from setuptools.command.develop import develop -from setuptools.command.install import install -from distutils.command.sdist import sdist as sdist_orig - - -IS_64 = sys.maxsize > 2 ** 32 -PACT_STANDALONE_VERSION = '1.88.83' -PACT_STANDALONE_SUFFIXES = ['osx.tar.gz', - 'linux-x86_64.tar.gz', - 'linux-x86.tar.gz', - 'win32.zip'] - -here = os.path.abspath(os.path.dirname(__file__)) - -about = {} -with open(os.path.join(here, "pact", "__version__.py")) as f: - exec(f.read(), about) - -class sdist(sdist_orig): - """Subclass sdist to download all standalone ruby applications into ./pact/bin.""" - - def run(self): - """Installs the dist.""" - package_bin_path = os.path.join(os.path.dirname(__file__), 'pact', 'bin') - - if os.path.exists(package_bin_path): - shutil.rmtree(package_bin_path, ignore_errors=True) - os.mkdir(package_bin_path) - - for suffix in PACT_STANDALONE_SUFFIXES: - filename = ('pact-{version}-{suffix}').format(version=PACT_STANDALONE_VERSION, suffix=suffix) - download_ruby_app_binary(package_bin_path, filename, suffix) - super().run() - - -class PactPythonDevelopCommand(develop): - """ - Custom develop mode installer for pact-python. - - When the package is installed using `python setup.py develop` or - `pip install -e` it will download and unpack the appropriate Pact - mock service and provider verifier. - """ - - def run(self): - """Install ruby command.""" - develop.run(self) - package_bin_path = os.path.join(os.path.dirname(__file__), 'pact', 'bin') - if not os.path.exists(package_bin_path): - os.mkdir(package_bin_path) - - install_ruby_app(package_bin_path, download_bin_path=None) - - -class PactPythonInstallCommand(install): - """ - Custom installer for pact-python. - - Installs the Python package and unpacks the platform appropriate version - of the Ruby mock service and provider verifier. - - User Options: - --bin-path An absolute folder path containing predownloaded pact binaries - that should be used instead of fetching from the internet. - """ - - user_options = install.user_options + [('bin-path=', None, None)] - - def initialize_options(self): - """Load our preconfigured options.""" - install.initialize_options(self) - self.bin_path = None - - def finalize_options(self): - """Load provided CLI arguments into our options.""" - install.finalize_options(self) - - def run(self): - """Install python binary.""" - install.run(self) - package_bin_path = os.path.join(self.install_lib, 'pact', 'bin') - if not os.path.exists(package_bin_path): - os.mkdir(package_bin_path) - install_ruby_app(package_bin_path, self.bin_path) - - -def install_ruby_app(package_bin_path: str, download_bin_path=None): - """ - Installs the ruby standalone application for this OS. - - :param package_bin_path: The path where we want our pact binaries unarchived. - :param download_bin_path: An optional path containing pre-downloaded pact binaries. - """ - binary = ruby_app_binary() - - # The compressed Pact .tar.gz, zip etc file is expected to be in download_bin_path (if provided). - # Otherwise we will look in package_bin_path. - source_dir = download_bin_path if download_bin_path else package_bin_path - pact_unextracted_path = os.path.join(source_dir, binary['filename']) - - if os.path.isfile(pact_unextracted_path): - # Already downloaded, so just need to extract - extract_ruby_app_binary(source_dir, package_bin_path, binary['filename']) - else: - if download_bin_path: - # An alternative source was provided, but did not contain the .tar.gz - raise RuntimeError('Could not find {} binary.'.format(pact_unextracted_path)) - else: - # Clean start, download an extract - download_ruby_app_binary(package_bin_path, binary['filename'], binary['suffix']) - extract_ruby_app_binary(package_bin_path, package_bin_path, binary['filename']) - - -def ruby_app_binary(): - """ - Determine the ruby app binary required for this OS. - - :return A dictionary of type {'filename': string, 'version': string, 'suffix': string } - """ - target_platform = platform.platform().lower() - - binary = ('pact-{version}-{suffix}') - - if 'darwin' in target_platform or 'macos' in target_platform: - suffix = 'osx.tar.gz' - elif 'linux' in target_platform and IS_64: - suffix = 'linux-x86_64.tar.gz' - elif 'linux' in target_platform: - suffix = 'linux-x86.tar.gz' - elif 'windows' in target_platform: - suffix = 'win32.zip' - else: - msg = ('Unfortunately, {} is not a supported platform. Only Linux,' - ' Windows, and OSX are currently supported.').format( - platform.platform()) - raise Exception(msg) - - binary = binary.format(version=PACT_STANDALONE_VERSION, suffix=suffix) - return {'filename': binary, 'version': PACT_STANDALONE_VERSION, 'suffix': suffix} - -def download_ruby_app_binary(path_to_download_to, filename, suffix): - """ - Download `binary` into `path_to_download_to`. - - :param path_to_download_to: The path where binaries should be downloaded. - :param filename: The filename that should be installed. - :param suffix: The suffix of the standalone app to install. - """ - uri = ('https://github.com/pact-foundation/pact-ruby-standalone/releases' - '/download/v{version}/pact-{version}-{suffix}') - - if sys.version_info.major == 2: - from urllib import urlopen - else: - from urllib.request import urlopen - - path = os.path.join(path_to_download_to, filename) - resp = urlopen(uri.format(version=PACT_STANDALONE_VERSION, suffix=suffix)) - with open(path, 'wb') as f: - if resp.code == 200: - f.write(resp.read()) - else: - raise RuntimeError( - 'Received HTTP {} when downloading {}'.format( - resp.code, resp.url)) - -def extract_ruby_app_binary(source, destination, binary): - """ - Extract the ruby app binary from `source` into `destination`. - - :param source: The location of the binary to unarchive. - :param destination: The location to unarchive to. - :param binary: The binary that needs to be unarchived. - """ - path = os.path.join(source, binary) - if 'windows' in platform.platform().lower(): - with ZipFile(path) as f: - f.extractall(destination) - else: - with tarfile.open(path) as f: - def is_within_directory(directory, target): - - abs_directory = os.path.abspath(directory) - abs_target = os.path.abspath(target) - - prefix = os.path.commonprefix([abs_directory, abs_target]) - - return prefix == abs_directory - - def safe_extract(tar, path=".", members=None, *, numeric_owner=False): - - for member in tar.getmembers(): - member_path = os.path.join(path, member.name) - if not is_within_directory(path, member_path): - raise Exception("Attempted Path Traversal in Tar File") - - tar.extractall(path, members, numeric_owner=numeric_owner) - - safe_extract(f, destination) - - -def read(filename): - """Read file contents.""" - path = os.path.realpath(os.path.join(os.path.dirname(__file__), filename)) - with open(path, 'rb') as f: - return f.read().decode('utf-8') - - -dependencies = [ - 'psutil>=5.9.4', - 'six>=1.16.0', - 'fastapi>=0.67.0', - 'urllib3>=1.26.12', -] - -if sys.version_info < (3, 7): - dependencies += [ - 'click<=8.0.4', - 'httpx==0.22.0', - 'requests==2.27.1', - 'uvicorn==0.16.0', - ] -else: - dependencies += [ - 'click>=8.1.3', - 'httpx==0.23.3', - 'requests>=2.28.0', - 'uvicorn>=0.19.0', - ] - -# Classifiers: available ones listed at https://pypi.org/classifiers -CLASSIFIERS = [ - 'Development Status :: 5 - Production/Stable', - - 'Operating System :: OS Independent', - - 'Intended Audience :: Developers', - - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - - 'License :: OSI Approved :: MIT License', -] - -if __name__ == '__main__': - setup( - cmdclass={ - 'develop': PactPythonDevelopCommand, - 'install': PactPythonInstallCommand, - 'sdist': sdist}, - name='pact-python', - version=about['__version__'], - description=( - 'Tools for creating and verifying consumer driven ' - 'contracts using the Pact framework.'), - long_description=read('README.md'), - long_description_content_type='text/markdown', - author='Matthew Balvanz', - author_email='matthew.balvanz@workiva.com', - url='https://github.com/pact-foundation/pact-python', - entry_points=''' - [console_scripts] - pact-verifier=pact.cli.verify:main - ''', - classifiers=CLASSIFIERS, - python_requires='>=3.6,<4', - install_requires=dependencies, - packages=['pact', 'pact.cli'], - package_data={'pact': ['bin/*']}, - package_dir={'pact': 'pact'}, - license='MIT License') diff --git a/src/pact/__init__.py b/src/pact/__init__.py new file mode 100644 index 000000000..a84f9ab38 --- /dev/null +++ b/src/pact/__init__.py @@ -0,0 +1,129 @@ +""" +Pact Python V3. + +This package provides contract testing capabilities for Python applications +using the [Pact specification](https://docs.pact.io/). Built on the [Pact Rust +FFI library](https://github.com/pact-foundation/pact-reference), it offers full +support for all Pact features and maintains compatibility with other Pact +implementations. + +## Package Structure + +### Main Classes + +The primary entry points for contract testing are: + +- [`Pact`][Pact]: For consumer-side contract testing, defining expected + interactions and generating contract files. +- [`Verifier`][Verifier]: For provider-side contract verification, + validating that a provider implementation satisfies consumer contracts. + +These functions are defined in [`pact.pact`][pact] and +[`pact.verifier`][verifier] and re-exported for convenience. + +### Matching and Generation + +For flexible contract definitions, use the matching and generation modules: + +```python +from pact import match, generate, xml + +# Import modules, not individual functions +# Use functions via module namespace to avoid shadowing built-ins +user_id = match.int(min=1) +user_name = match.str(size=20) +created_at = match.datetime() + +# Generators work similarly +response_id = generate.uuid() +score = generate.float(precision=2) + +# XML bodies use the xml module +response = xml.body( + xml.element( + "user", + xml.element("id", match.int(123)), + xml.element("name", match.str("Alice")), + ) +) +``` + +The functions within these modules are designed to align with a number of +Python built-in types and functions. As such, the module should be imported +as a whole, rather than importing individual functions to avoid potential +shadowing of built-ins. + +### Utility Modules + +- `error`: Exception classes used throughout the package. Typically not + imported directly unless implementing custom error handling. +- `types`: Type definitions and protocols. This does not provide much + functionality, but will be used by your type-checker. + +## Basic Usage + +### Consumer Testing + +```python +from pact import Pact, match + +# Create a consumer contract +pact = Pact("consumer", "provider") + +# Define expected interactions +( + pact + .upon_receiving("get user") + .given("user exists") + .with_request(method="GET", path="/users/123") + .will_respond_with( + status=200, + body={ + "id": match.int(123), + "name": match.str("alice"), + }, + ) +) + +# Use in tests with the mock server +with pact.serve() as server: + # Make requests to server.url + # Test your consumer code + pass +``` + +### Provider Verification + +```python +from pact import Verifier + +# Verify provider against contracts +verifier = Verifier() +verifier.verify_with_broker( + provider="provider-name", + broker_url="https://my-org.pactflow.io", +) +``` + +For more detailed usage examples, see the +[examples](https://pact-foundation.github.io/pact-python/examples). +""" + +from __future__ import annotations + +from pact import xml as xml +from pact.__version__ import __version__, __version_tuple__ +from pact.pact import Pact +from pact.verifier import Verifier + +__url__ = "https://github.com/pact-foundation/pact-python" +__license__ = "MIT" +__author__ = "Pact Foundation" + +__all__ = [ + "Pact", + "Verifier", + "__version__", + "__version_tuple__", + "xml", +] diff --git a/src/pact/__version__.py b/src/pact/__version__.py new file mode 100644 index 000000000..3b6fe0250 --- /dev/null +++ b/src/pact/__version__.py @@ -0,0 +1,10 @@ +"""Version information for pact-python.""" + +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("pact-python") +except PackageNotFoundError: + __version__ = "unknown" + +__version_tuple__ = tuple(int(x) for x in __version__.split(".") if x.isdigit()) diff --git a/src/pact/_server.py b/src/pact/_server.py new file mode 100644 index 000000000..e05c9dfe4 --- /dev/null +++ b/src/pact/_server.py @@ -0,0 +1,510 @@ +""" +Internal Pact server. + +Pact typically communicates directly with the client/server under test over +HTTP. When testing message interactions, however, Pact abstracts away the +transport layer and instead verifies the message payload and metadata directly. + +Internally, this verification process still requires some form of transport +layer to communication with the underlying Pact Core library. This is where the +Pact server comes in. It is a lightweight HTTP server which translates +communications from the underlying Pact Core library with direct Python function +calls. + +In order to be able to both handle incoming requests, and verify the +interactions, the server is started in a separate thread within the same Python +process. This does have some risks, as the server is not isolated from the rest +of the Python process. This also relies on the requests being made sequentially +and not in parallel, as the server (and more specifically, the verification +process), is _not_ thread-safe. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import warnings +from collections.abc import Callable +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +from typing import TYPE_CHECKING, Any, Generic, TypeVar +from urllib.parse import urlparse + +from pact import __version__ +from pact._util import find_free_port +from pact.types import Message + +if TYPE_CHECKING: + from types import TracebackType + + from typing_extensions import Self + + +logger = logging.getLogger(__name__) + + +_C = TypeVar("_C", bound=Callable[..., Any]) +_CM = TypeVar("_CM", bound=Callable[..., Message]) +_CN = TypeVar("_CN", bound=Callable[..., None]) + + +class HandlerHttpServer(ThreadingHTTPServer, Generic[_C]): + """ + A simple HTTP server with an custom handler function. + + Both the message relay and state handler need to be instantiated with a + user-provided function which is accessed during the handling of a request. + As Python's lightweight HTTP server makes the underlying server instance + accessible while processing a request, we can use this to pass the handler + function to the request handler. + """ + + def __init__( + self, + *args: Any, # noqa: ANN401 + handler: _C, + **kwargs: Any, # noqa: ANN401 + ) -> None: + """ + Initialize the HTTP server. + + Args: + handler: + The handler function to call when a request is received. + + *args: + Additional arguments to pass to the server. These are not used + by this class and are passed to the superclass. + + **kwargs: + Additional keyword arguments to pass to the server. These are + not used by this class and are passed to the superclass. + """ + self.handler = handler + super().__init__(*args, **kwargs) + + +################################################################################ +## Message Relay +################################################################################ + + +class MessageProducer(Generic[_CM]): + """ + Internal message producer server. + + The Pact server is a lightweight HTTP server which translates communications + from the underlying Pact Core library with direct Python function calls. + + The server is responsible for starting and stopping the Pact server, as well + as handling the communication between the server and the underlying Pact + Core library. + """ + + def __init__( + self, + handler: _CM, + host: str = "localhost", + port: int | None = None, + ) -> None: + """ + Initialize the Pact server. + + Args: + handler: + The handler function to call when a request is received. + + The handler must accept two positional arguments: + + - The body of the request if present as a byte string, or + `None`. + - The metadata of the request if present as a dictionary, or + `None`. + + The handler function must return a byte string response, or + `None`. + + host: + The host to run the server on. + + port: + The port to run the server on. If not provided, a free port will + be found. + """ + self._host = host + self._port = port or find_free_port() + + self._handler = handler + + self._server: HandlerHttpServer[_CM] | None = None + self._thread: Thread | None = None + + @property + def host(self) -> str: + """ + Server host. + """ + return self._host + + @property + def port(self) -> int: + """ + Server port. + """ + return self._port + + @property + def path(self) -> str: + """ + Server path. + """ + return MessageProducerHandler.MESSAGE_PATH + + @property + def url(self) -> str: + """ + Server URL. + """ + return f"http://{self.host}:{self.port}{self.path}" + + def __enter__(self) -> Self: + """ + Enter the Pact message server context. + + This method starts the Pact server in a separate thread to handle the + communication between the server and the underlying Pact Core library. + + Returns: + Self: + The started message producer server instance. + """ + self._server = HandlerHttpServer( + (self.host, self.port), + MessageProducerHandler, + handler=self._handler, + ) + self._thread = Thread( + target=self._server.serve_forever, + name="Pact Message Relay Server", + ) + self._thread.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """ + Exit the Pact message server context. + + Args: + exc_type: + The exception type, if an exception was raised. + + exc_value: + The exception value, if an exception was raised. + + traceback: + The traceback, if an exception was raised. + """ + if not self._thread or not self._server: + warnings.warn( + "Exiting server context despite server not being started.", + stacklevel=2, + ) + return + + self._server.shutdown() + self._thread.join() + + +class MessageProducerHandler(SimpleHTTPRequestHandler, Generic[_CM]): + """ + Request handler for the message relay server. + + The `do_GET` and `do_POST` methods allow the server to handle GET and POST + requests. A new instance of this class is created for each request and + attributes can be inspected to determine the request details and respond + accordingly. + + Specifically, the request details can be found in the following attributes: + + - `self.path` contains the HTTP path of the request. + - `self.headers` contains the HTTP headers of the request. + - `self.rfile` is an input stream containing the body of the request. + + The response can be sent using: + + - `self.send_response(code, message)` to set the response code and message. + - `self.send_header(header, value)` to set a response header. + - `self.end_headers()` to end the headers. + """ + + if TYPE_CHECKING: + server: HandlerHttpServer[_CM] + + MESSAGE_PATH = "/_pact/message" + + def version_string(self) -> str: + """ + Return the server version string. + + This method is overridden to return a custom server version string. + """ + return f"pact-python/{__version__} message-relay" + + def do_POST(self) -> None: + """ + Handle a POST request. + + This method is called when a POST request is received by the server. + """ + logger.debug( + "Received POST request: %s", + self.path, + extra={"headers": self.headers}, + ) + self.close_connection = True + if self.path != self.MESSAGE_PATH: + self.send_error(404, "Not Found") + return + + data: dict[str, Any] = json.loads( + self.rfile.read(int(self.headers.get("Content-Length", -1))) + ) + + description: str | None = data.pop("description", None) + if not description: + logger.error("No description provided in message.") + self.send_error(400, "Bad Request") + return + + self.send_response(200, "OK") + + message = self.server.handler(description, data) + + metadata = message.get("metadata") or {} + if content_type := message.get("content_type"): + self.send_header("Content-Type", content_type) + if "contentType" not in metadata: + metadata["contentType"] = content_type + + if metadata: + self.send_header( + "Pact-Message-Metadata", + base64.b64encode(json.dumps(metadata).encode()).decode(), + ) + + contents = message.get("contents", b"") + self.send_header("Content-Length", str(len(contents))) + self.end_headers() + self.wfile.write(contents) + + def do_GET(self) -> None: + """ + Handle a GET request. + + This method is called when a GET request is received by the server. + """ + logger.debug( + "Received GET request: %s", + self.path, + extra={"headers": self.headers}, + ) + self.close_connection = True + self.send_error(404, "Not Found") + + +################################################################################ +## State Handler +################################################################################ + + +class StateCallback(Generic[_CN]): + """ + Internal server for handling state callbacks. + + The state handler is a lightweight HTTP server which listens for state + change requests from the underlying Pact Core library. It then calls a + user-provided function to handle the setup/teardown of the state. + """ + + def __init__( + self, + handler: _CN, + host: str = "localhost", + port: int | None = None, + ) -> None: + """ + Initialize the state handler. + + Args: + handler: + The handler function to call when a state callback is received. + + The handler must accept three positional arguments: + + - The state name as a string. + - The action as a string. + - The params as a dictionary. + + host: + The host to run the server on. + + port: + The port to run the server on. If not provided, a free port will + be found. + """ + self._host = host + self._port = port or find_free_port() + + self._handler = handler + + self._server: HandlerHttpServer[_CN] | None = None + self._thread: Thread | None = None + + @property + def host(self) -> str: + """ + Server host. + """ + return self._host + + @property + def port(self) -> int: + """ + Server port. + """ + return self._port + + @property + def url(self) -> str: + """ + Server URL. + """ + return f"http://{self.host}:{self.port}{StateCallbackHandler.CALLBACK_PATH}" + + def __enter__(self) -> Self: + """ + Enter the state handler context. + + This method starts the Pact server in a separate thread to handle the + communication between the server and the underlying Pact Core library. + + Returns: + The started state callback server instance. + """ + self._server = HandlerHttpServer( + (self.host, self.port), + StateCallbackHandler, + handler=self._handler, + ) + self._thread = Thread( + target=self._server.serve_forever, + name="Pact Message Relay Server", + ) + self._thread.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """ + Exit the state handler context. + + Args: + exc_type: + The exception type, if an exception was raised. + + exc_value: + The exception value, if an exception was raised. + + traceback: + The traceback, if an exception was raised. + """ + if not self._thread or not self._server: + warnings.warn( + "Exiting server context despite server not being started.", + stacklevel=2, + ) + return + + self._server.shutdown() + self._thread.join() + + +class StateCallbackHandler(SimpleHTTPRequestHandler, Generic[_CN]): + """ + Request handler for the state callback server. + + See the docs of [`MessageRelayHandler`](#messagerelayhandler) for more + information on how to handle requests. + """ + + if TYPE_CHECKING: + server: HandlerHttpServer[_CN] + + CALLBACK_PATH = "/_pact/state" + + def version_string(self) -> str: + """ + Return the server version string. + + This method is overridden to return a custom server version string. + """ + return f"pact-python/{__version__} state-callback" + + def do_POST(self) -> None: + """ + Handle a POST request. + + This method is called when a POST request is received by the server. + """ + logger.debug( + "Received POST request: %s", + self.path, + extra={"headers": self.headers}, + ) + self.close_connection = True + url = urlparse(self.path) + if url.path != self.CALLBACK_PATH: + self.send_error(404, "Not Found") + return + + content_length = self.headers.get("Content-Length") + if not content_length: + self.send_error(400, "Bad Request") + return + data = json.loads(self.rfile.read(int(content_length))) + + state = data.pop("state") + action = data.pop("action") + params = data.pop("params") + + if state is None or action is None: + self.send_error(400, "Bad Request") + return + + self.server.handler(state, action, params) + self.send_response(200, "OK") + self.end_headers() + + def do_GET(self) -> None: + """ + Handle a GET request. + + This method is called when a GET request is received by the server. + """ + logger.debug( + "Received GET request: %s", + self.path, + extra={"headers": self.headers}, + ) + self.close_connection = True + self.send_error(404, "Not Found") diff --git a/src/pact/_util.py b/src/pact/_util.py new file mode 100644 index 000000000..46df624f1 --- /dev/null +++ b/src/pact/_util.py @@ -0,0 +1,286 @@ +""" +Utility functions for Pact. + +This module defines a number of utility functions that are used in specific +contexts within the Pact library. These functions are not intended to be +used directly by consumers of the library and as such, may change without +notice. +""" + +from __future__ import annotations + +import inspect +import logging +import socket +import warnings +from functools import partial +from inspect import Parameter, _ParameterKind +from typing import TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + +logger = logging.getLogger(__name__) + +_PYTHON_FORMAT_TO_JAVA_DATETIME = { + "a": "EEE", + "A": "EEEE", + "b": "MMM", + "B": "MMMM", + # c is locale dependent, so we can't convert it directly. + "d": "dd", + "f": "SSSSSS", + "G": "YYYY", + "H": "HH", + "I": "hh", + "j": "DDD", + "m": "MM", + "M": "mm", + "p": "a", + "S": "ss", + "u": "u", + "U": "ww", + "V": "ww", + # w is 0-indexed in Python, but 1-indexed in Java. + "W": "ww", + # x is locale dependent, so we can't convert it directly. + # X is locale dependent, so we can't convert it directly. + "y": "yy", + "Y": "yyyy", + "z": "Z", + "Z": "z", + "%": "%", + ":z": "XXX", +} + +_T = TypeVar("_T") + + +def strftime_to_simple_date_format(python_format: str) -> str: + """ + Convert a Python datetime format string to Java SimpleDateFormat format. + + Python uses [`strftime` + codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) + which are ultimately based on the C `strftime` function. Java uses + [`SimpleDateFormat` + codes](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + which generally have corresponding codes, but with some differences. + + Note that this function strictly supports codes explicitly defined in the + Python documentation. Locale-dependent codes are not supported, and codes + supported by the underlying C library but not Python are not supported. For + examples, `%c`, `%x`, and `%X` are not supported as they are locale + dependent, and `%D` is not supported as it is not part of the Python + documentation (even though it may be supported by the underlying C and + therefore work in some Python implementations). + + Args: + python_format: + The Python datetime format string to convert. + + Returns: + The equivalent Java SimpleDateFormat format string. + """ + # Each Python format code is exactly two characters long, so we can + # safely iterate through the string. + idx = 0 + result: str = "" + escaped = False + + while idx < len(python_format): + c = python_format[idx] + idx += 1 + + if c == "%": + c = python_format[idx] + if escaped: + result += "'" + escaped = False + result += format_code_to_java_format(c) + # Increment another time to skip the second character of the + # Python format code. + idx += 1 + continue + + if c == "'": + # In Java, single quotes are used to escape characters. + # To insert a single quote, we need to insert two single quotes. + # It doesn't matter if we're in an escape sequence or not, as + # Java treats them the same. + result += "''" + continue + + if not escaped and c.isalpha(): + result += "'" + escaped = True + result += c + + if escaped: + result += "'" + return result + + +def format_code_to_java_format(code: str) -> str: + """ + Convert a single Python format code to a Java SimpleDateFormat format. + + Args: + code: + The Python format code to convert, without the leading `%`. This + will typically be a single character, but may be two characters for + some codes. + + Returns: + The equivalent Java SimpleDateFormat format string. + + Raises: + ValueError: + If the code is locale-dependent or unsupported. + """ + if code in ["U", "V", "W"]: + warnings.warn( + f"The Java equivalent for `%{code}` is locale dependent.", + stacklevel=3, + ) + + # The following are locale-dependent, and aren't directly convertible. + if code in ["c", "x", "X"]: + msg = f"Cannot convert locale-dependent Python format code `%{code}` to Java" + raise ValueError(msg) + + # The following codes simply do not have a direct equivalent in Java. + if code == "w": + msg = f"Python format code `%{code}` is not supported in Java" + raise ValueError(msg) + + if code in _PYTHON_FORMAT_TO_JAVA_DATETIME: + return _PYTHON_FORMAT_TO_JAVA_DATETIME[code] + + msg = f"Unsupported Python format code `%{code}`" + raise ValueError(msg) + + +def find_free_port() -> int: + """ + Find a free port. + + This is used to find a free port to host the API on when running locally. It + is allocated, and then released immediately so that it can be used by the + API. + + Returns: + The port number. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def apply_args(f: Callable[..., _T], args: Mapping[str, object]) -> _T: # noqa: C901 + """ + Apply arguments to a function. + + This function passes through the arguments to the function, doing so + intelligently by performing runtime introspection to determine whether + it is possible to pass arguments by name, and falling back to positional + arguments if not. + + Args: + f: + The function to apply the arguments to. + + args: + The arguments to apply. The dictionary is ordered such that, if an + argument cannot be passed by name, it will be passed by position + as per the order of the keys in the dictionary. + + Returns: + The result of the function. + """ + signature = inspect.signature(f) + f_name = ( + f.__qualname__ + if hasattr(f, "__qualname__") + else f"{type(f).__module__}.{type(f).__name__}" + ) + args = dict(args) + + # If the signature has a `*args` parameter, then parameters which appear as + # positional-or-keyword must be passed as positional arguments. + if any( + param.kind == Parameter.VAR_POSITIONAL + for param in signature.parameters.values() + ): + positional_match: list[_ParameterKind] = [ + Parameter.POSITIONAL_ONLY, + Parameter.POSITIONAL_OR_KEYWORD, + ] + keyword_match: list[_ParameterKind] = [Parameter.KEYWORD_ONLY] + else: + positional_match = [Parameter.POSITIONAL_ONLY] + keyword_match = [Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY] + + # First, we inspect the keyword arguments and try and pass in some arguments + # by currying them in. + for param in signature.parameters.values(): + if param.name not in args: + # If a parameter is not known, we will ignore it. + # + # If the ignored parameter doesn't have a default value, it will + # result in a exception, but we will also warn the user here. + if param.default == Parameter.empty and param.kind not in [ + Parameter.VAR_POSITIONAL, + Parameter.VAR_KEYWORD, + ]: + msg = ( + f"Function {f_name} appears to have required " + f"parameter '{param.name}' that will not be passed" + ) + warnings.warn(msg, stacklevel=2) + + continue + + if param.kind in positional_match: + # We iterate through the parameters in order that they are defined, + # making it fine to pass them in by position one at a time. + f = partial(f, args[param.name]) + del args[param.name] + + if param.kind in keyword_match: + f = partial(f, **{param.name: args[param.name]}) + del args[param.name] + continue + + # At this stage, we have checked all arguments. If we have any arguments + # remaining, we will try and pass them through variadic arguments if the + # function accepts them. + if args: + if Parameter.VAR_KEYWORD in [ + param.kind for param in signature.parameters.values() + ]: + f = partial(f, **args) + args.clear() + elif Parameter.VAR_POSITIONAL in [ + param.kind for param in signature.parameters.values() + ]: + f = partial(f, *args.values()) + args.clear() + else: + logger.debug( + "Function %s was called with remaining arguments: %s. " + "This is not necessarily a bug; whether extra arguments are " + "acceptable depends on the function's signature and intended usage.", + f_name, + list(args.keys()), + extra={ + "function_name": f_name, + "remaining_args": list(args.keys()), + }, + ) + + try: + return f() + except Exception: + logger.exception("Error occurred while calling function %s", f_name) + raise diff --git a/src/pact/error.py b/src/pact/error.py new file mode 100644 index 000000000..d247d6987 --- /dev/null +++ b/src/pact/error.py @@ -0,0 +1,1125 @@ +""" +Error classes for Pact. +""" + +from __future__ import annotations + +import copy +import logging +from abc import ABC, abstractmethod +from typing import Any + +logger = logging.getLogger(__name__) + + +class PactError(Exception, ABC): + """ + Base class for exceptions raised by the Pact module. + """ + + +class InteractionVerificationError(PactError): + """ + Exception raised due during the verification of an interaction. + + This error is raised when an error occurs during the manual verification of an + interaction. This is typically raised when the consumer fails to handle the + interaction correctly thereby generating its own exception. The cause of the + error is stored in the `error` attribute. + """ + + def __init__(self, description: str, error: Exception) -> None: + """ + Initialise a new InteractionVerificationError. + + Args: + description: + Description of the interaction that failed verification. + + error: + Error that occurred during the verification of the interaction. + """ + super().__init__(f"Error verifying interaction '{description}': {error}") + self._description = description + self._error = error + + @property + def description(self) -> str: + """ + Description of the interaction that failed verification. + """ + return self._description + + @property + def error(self) -> Exception: + """ + Error that occurred during the verification of the interaction. + """ + return self._error + + +class PactVerificationError(PactError): + """ + Exception raised due to errors in the verification of a Pact. + + This is raised when performing manual verification of the Pact through the + [`verify`][pact.Pact.verify] method: + + ```python + pact = Pact("consumer", "provider") + # Define interactions... + try: + pact.verify(handler, kind="Async") + except PactVerificationError as e: + print(e.errors) + ``` + + All of the errors that occurred during the verification of all of the + interactions are stored in the `errors` attribute. + + This is different from the [`MismatchesError`][error.MismatchesError] which + is raised when there are mismatches detected by the mock server. + """ + + def __init__(self, errors: list[InteractionVerificationError]) -> None: + """ + Initialise a new PactVerificationError. + + Args: + errors: + Errors that occurred during the verification of the Pact. + """ + super().__init__(f"Error verifying Pact (count: {len(errors)})") + self._errors = errors + + @property + def errors(self) -> list[InteractionVerificationError]: + """ + Errors that occurred during the verification of the Pact. + """ + return self._errors + + +class Mismatch(ABC): + """ + A mismatch between the Pact contract and the actual interaction. + + See + https://github.com/pact-foundation/pact-reference/blob/f5ddf3d353149ae0fb539a1616eeb8544509fdfc/rust/pact_matching/src/lib.rs#L880 + for the underlying source of the data. + """ + + @property + @abstractmethod + def type(self) -> str: + """ + Type of the mismatch. + """ + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Mismatch: # noqa: C901, PLR0911 + """ + Create a new Mismatch from a dictionary. + + Args: + data: + Data for the mismatch. + + Returns: + A new Mismatch object. + """ + if "type" in data: + mismatch_type = data.pop("type") + # Pact mismatches + if mismatch_type in ["MissingRequest", "missing-request"]: + return MissingRequest(**data) + if mismatch_type in ["RequestNotFound", "request-not-found"]: + return RequestNotFound(**data) + if mismatch_type in ["RequestMismatch", "request-mismatch"]: + return RequestMismatch(**data) + + # Interaction mismatches + if mismatch_type in ["MethodMismatch", "method-mismatch"]: + return MethodMismatch(**data) + if mismatch_type in ["PathMismatch", "path-mismatch"]: + return PathMismatch(**data) + if mismatch_type in ["StatusMismatch", "status-mismatch"]: + return StatusMismatch(**data) + if mismatch_type in ["QueryMismatch", "query-mismatch"]: + return QueryMismatch(**data) + if mismatch_type in ["HeaderMismatch", "header-mismatch"]: + return HeaderMismatch(**data) + if mismatch_type in ["BodyTypeMismatch", "body-type-mismatch"]: + return BodyTypeMismatch(**data) + if mismatch_type in ["BodyMismatch", "body-mismatch"]: + return BodyMismatch(**data) + if mismatch_type in ["MetadataMismatch", "metadata-mismatch"]: + return MetadataMismatch(**data) + logger.warning("RequestMismatch not implemented") + + logger.warning("Unknown mismatch type: %s (%r)", mismatch_type, data) + return GenericMismatch(**data, type=mismatch_type) + return GenericMismatch(**data) + + +class GenericMismatch(Mismatch): + """ + Generic mismatch between the Pact contract and the actual interaction. + + This is used when the mismatch is not otherwise covered by a specific + mismatch below. + """ + + def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 + """ + Initialise a new GenericMismatch. + + Args: + kwargs: + Data for the mismatch. + """ + self._data = kwargs + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return self._data.get("type", "UnknownMismatchType") + + def __repr__(self) -> str: + """ + Information-rich string representation of the GenericMismatch. + """ + return "GenericMismatch({})".format( + ", ".join(f"{key}={value!r}" for key, value in self._data.items()) + ) + + def __str__(self) -> str: + """ + Informal string representation of the GenericMismatch. + """ + return f"Generic mismatch ({self.type}): {self._data}" + + +class MissingRequest(Mismatch): + """ + Mismatch due to a missing request. + """ + + def __init__(self, method: str, path: str, request: dict[str, Any]) -> None: + """ + Initialise a new MissingRequest. + + Args: + method: + HTTP method of the missing request. + + path: + Path of the missing request. + + request: + Details of the missing request. + """ + self._method = method + self._path = path + self._request = request + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return "MissingRequest" + + @property + def method(self) -> str: + """ + HTTP method of the missing request. + """ + return self._method + + @property + def path(self) -> str: + """ + Path of the missing request. + """ + return self._path + + @property + def request(self) -> dict[str, Any]: + """ + Details of the missing request. + """ + return self._request + + def __repr__(self) -> str: + """ + Information-rich string representation of the MissingRequest. + """ + return "MissingRequest({})".format( + ", ".join([ + f"method={self.method!r}", + f"path={self.path!r}", + f"request={self.request!r}", + ]) + ) + + def __str__(self) -> str: + """ + Informal string representation of the MissingRequest. + """ + extra = copy.deepcopy(self.request) + extra.pop("method") + extra.pop("path") + return f"Missing request: {self.method} {self.path}: {extra}" + + +class RequestNotFound(Mismatch): + """ + Mismatch due to a request not being found. + """ + + def __init__(self, method: str, path: str, request: dict[str, Any]) -> None: + """ + Initialise a new RequestNotFound. + + Args: + method: + HTTP method of the request not found. + + path: + Path of the request not found. + + request: + Details of the request not found. + """ + self._method = method + self._path = path + self._request = request + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return "RequestNotFound" + + @property + def method(self) -> str: + """ + HTTP method of the request not found. + """ + return self._method + + @property + def path(self) -> str: + """ + Path of the request not found. + """ + return self._path + + @property + def request(self) -> dict[str, Any]: + """ + Details of the request not found. + """ + return self._request + + def __repr__(self) -> str: + """ + Information-rich string representation of the RequestNotFound. + """ + return "RequestNotFound({})".format( + ", ".join([ + f"method={self.method!r}", + f"path={self.path!r}", + f"request={self.request!r}", + ]) + ) + + def __str__(self) -> str: + """ + Informal string representation of the RequestNotFound. + """ + extra = copy.deepcopy(self.request) + extra.pop("method") + extra.pop("path") + return f"Request not found: {self.method} {self.path}: {extra}" + + +class RequestMismatch(Mismatch): + """ + Mismatch due to an incorrect request. + """ + + def __init__( + self, method: str, path: str, mismatches: list[dict[str, Any]] + ) -> None: + """ + Initialise a new RequestMismatch. + + Args: + method: + HTTP method of the request. + + path: + Path of the request. + + mismatches: + List of mismatches in the request. + """ + self._method = method + self._path = path + self._mismatches = [Mismatch.from_dict(m) for m in mismatches] + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return "RequestMismatch" + + @property + def method(self) -> str: + """ + HTTP method of the request. + """ + return self._method + + @property + def path(self) -> str: + """ + Path of the request. + """ + return self._path + + @property + def mismatches(self) -> list[Mismatch]: + """ + List of mismatches in the request. + """ + return self._mismatches + + def __repr__(self) -> str: + """ + Information-rich string representation of the RequestMismatch. + """ + return "RequestMismatch({})".format( + ", ".join([ + f"method={self.method!r}", + f"path={self.path!r}", + f"mismatches={self.mismatches!r}", + ]) + ) + + def __str__(self) -> str: + """ + Informal string representation of the RequestMismatch. + """ + return "\n".join([ + f"Request mismatch: {self.method} {self.path}", + *(f" ({i + 1}) {m}" for i, m in enumerate(self.mismatches)), + ]) + + +class MethodMismatch(Mismatch): + """ + Mismatch due to an incorrect HTTP method. + """ + + def __init__(self, expected: str, actual: str) -> None: + """ + Initialise a new MethodMismatch. + + Args: + expected: + Expected HTTP method. + + actual: + Actual HTTP method. + """ + self._expected = expected + self._actual = actual + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return "MethodMismatch" + + @property + def expected(self) -> str: + """ + Expected HTTP method. + """ + return self._expected + + @property + def actual(self) -> str: + """ + Actual HTTP method. + """ + return self._actual + + def __repr__(self) -> str: + """ + Information-rich string representation of the MethodMismatch. + """ + return "MethodMismatch({})".format( + ", ".join([ + f"expected={self.expected!r}", + f"actual={self.actual!r}", + ]) + ) + + def __str__(self) -> str: + """ + Informal string representation of the MethodMismatch. + """ + return f"Method mismatch: expected {self.expected}, got {self.actual}" + + +class PathMismatch(Mismatch): + """ + Mismatch due to an incorrect path. + """ + + def __init__(self, expected: str, actual: str, mismatch: str) -> None: + """ + Initialise a new PathMismatch. + + Args: + expected: + Expected path. + + actual: + Actual path. + + mismatch: + Mismatch between the expected and actual paths. + """ + self._expected = expected + self._actual = actual + self._mismatch = mismatch + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return "PathMismatch" + + @property + def expected(self) -> str: + """ + Expected path. + """ + return self._expected + + @property + def actual(self) -> str: + """ + Actual path. + """ + return self._actual + + @property + def mismatch(self) -> str: + """ + Mismatch between the expected and actual paths. + """ + return self._mismatch + + def __repr__(self) -> str: + """ + Information-rich string representation of the PathMismatch. + """ + return "PathMismatch({})".format( + ", ".join([ + f"expected={self.expected!r}", + f"actual={self.actual!r}", + f"mismatch={self.mismatch!r}", + ]) + ) + + def __str__(self) -> str: + """ + Informal string representation of the PathMismatch. + """ + return ( + "Path mismatch: " + f"expected {self.expected}, got {self.actual} " + f"({self.mismatch})" + ) + + +class StatusMismatch(Mismatch): + """ + Mismatch due to an incorrect HTTP status code. + """ + + def __init__(self, expected: int, actual: int, mismatch: str) -> None: + """ + Initialise a new StatusMismatch. + + Args: + expected: + Expected HTTP status code. + + actual: + Actual HTTP status code. + + mismatch: + Description of the mismatch. + """ + self._expected = expected + self._actual = actual + self._mismatch = mismatch + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return "StatusMismatch" + + @property + def expected(self) -> int: + """ + Expected HTTP status code. + """ + return self._expected + + @property + def actual(self) -> int: + """ + Actual HTTP status code. + """ + return self._actual + + @property + def mismatch(self) -> str: + """ + Description of the mismatch. + """ + return self._mismatch + + def __repr__(self) -> str: + """ + Information-rich string representation of the StatusMismatch. + """ + return "StatusMismatch({})".format( + ", ".join([ + f"expected={self.expected!r}", + f"actual={self.actual!r}", + f"mismatch={self.mismatch!r}", + ]) + ) + + def __str__(self) -> str: + """ + Informal string representation of the StatusMismatch. + """ + return ( + "Status mismatch: " + f"expected {self.expected}, got {self.actual} " + f"({self.mismatch})" + ) + + +class QueryMismatch(Mismatch): + """ + Mismatch due to an incorrect query parameter. + """ + + def __init__( + self, + parameter: str, + expected: str, + actual: str, + mismatch: str, + ) -> None: + """ + Initialise a new QueryMismatch. + + Args: + parameter: + Query parameter name. + + expected: + Expected value of the query parameter. + + actual: + Actual value of the query parameter. + + mismatch: + Description of the mismatch. + """ + self._parameter = parameter + self._expected = expected + self._actual = actual + self._mismatch = mismatch + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return "QueryMismatch" + + @property + def parameter(self) -> str: + """ + Query parameter name. + """ + return self._parameter + + @property + def expected(self) -> str: + """ + Expected value of the query parameter. + """ + return self._expected + + @property + def actual(self) -> str: + """ + Actual value of the query parameter. + """ + return self._actual + + @property + def mismatch(self) -> str: + """ + Description of the mismatch. + """ + return self._mismatch + + def __repr__(self) -> str: + """ + Information-rich string representation of the QueryMismatch. + """ + return "QueryMismatch({})".format( + ", ".join([ + f"parameter={self.parameter!r}", + f"expected={self.expected!r}", + f"actual={self.actual!r}", + f"mismatch={self.mismatch!r}", + ]) + ) + + def __str__(self) -> str: + """ + Informal string representation of the QueryMismatch. + """ + return f"Query mismatch: {self.mismatch}" + + +class HeaderMismatch(Mismatch): + """ + Mismatch due to an incorrect header. + """ + + def __init__(self, key: str, expected: str, actual: str, mismatch: str) -> None: + """ + Initialise a new HeaderMismatch. + + Args: + key: + Header key. + + expected: + Expected value of the header. + + actual: + Actual value of the header. + + mismatch: + Description of the mismatch. + """ + self._key = key + self._expected = expected + self._actual = actual + self._mismatch = mismatch + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return "HeaderMismatch" + + @property + def key(self) -> str: + """ + Header key. + """ + return self._key + + @property + def expected(self) -> str: + """ + Expected value of the header. + """ + return self._expected + + @property + def actual(self) -> str: + """ + Actual value of the header. + """ + return self._actual + + @property + def mismatch(self) -> str: + """ + Description of the mismatch. + """ + return self._mismatch + + def __repr__(self) -> str: + """ + Information-rich string representation of the HeaderMismatch. + """ + return "HeaderMismatch({})".format( + ", ".join([ + f"key={self.key!r}", + f"expected={self.expected!r}", + f"actual={self.actual!r}", + f"mismatch={self.mismatch!r}", + ]) + ) + + def __str__(self) -> str: + """ + Informal string representation of the HeaderMismatch. + """ + return f"Header mismatch: {self.mismatch}" + + +class BodyTypeMismatch(Mismatch): + """ + Mismatch due to an incorrect content type of the body. + """ + + def __init__( # noqa: PLR0913 + self, + expected: str, + actual: str, + mismatch: str, + expected_body: bytes | None = None, + expectedBody: bytes | None = None, # noqa: N803 + actual_body: bytes | None = None, + actualBody: bytes | None = None, # noqa: N803 + ) -> None: + """ + Initialise a new BodyTypeMismatch. + + Args: + expected: + Expected content type of the body. + + actual: + Actual content type of the body. + + mismatch: + Description of the mismatch. + + expected_body: + Expected body content. + + actual_body: + Actual body content. + + expectedBody: + Alias for `expected_body`. + + actualBody: + Alias for `actual_body`. + """ + self._expected = expected + self._actual = actual + self._mismatch = mismatch + self._expected_body = expected_body or expectedBody + self._actual_body = actual_body or actualBody + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return "BodyTypeMismatch" + + @property + def expected(self) -> str: + """ + Expected content type of the body. + """ + return self._expected + + @property + def actual(self) -> str: + """ + Actual content type of the body. + """ + return self._actual + + @property + def mismatch(self) -> str: + """ + Description of the mismatch. + """ + return self._mismatch + + @property + def expected_body(self) -> bytes | None: + """ + Expected body content. + """ + return self._expected_body + + @property + def actual_body(self) -> bytes | None: + """ + Actual body content. + """ + return self._actual_body + + def __repr__(self) -> str: + """ + Information-rich string representation of the BodyTypeMismatch. + """ + return "BodyTypeMismatch({})".format( + ", ".join([ + f"expected={self.expected!r}", + f"actual={self.actual!r}", + f"mismatch={self.mismatch!r}", + f"expected_body={self.expected_body!r}", + f"actual_body={self.actual_body!r}", + ]) + ) + + def __str__(self) -> str: + """ + Informal string representation of the BodyTypeMismatch. + """ + return f"Body type mismatch: {self.mismatch}" + + +class BodyMismatch(Mismatch): + """ + Mismatch due to an incorrect body element. + """ + + def __init__( + self, + path: str, + expected: str, + actual: str, + mismatch: str, + ) -> None: + """ + Initialise a new BodyMismatch. + + Args: + path: + Path expression to where the mismatch occurred. + + expected: + Expected value. + + actual: + Actual value. + + mismatch: + Description of the mismatch. + """ + self._path = path + self._expected = expected + self._actual = actual + self._mismatch = mismatch + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return "BodyMismatch" + + @property + def path(self) -> str: + """ + Path expression to where the mismatch occurred. + """ + return self._path + + @property + def expected(self) -> str: + """ + Expected value. + """ + return self._expected + + @property + def actual(self) -> str: + """ + Actual value. + """ + return self._actual + + @property + def mismatch(self) -> str: + """ + Description of the mismatch. + """ + return self._mismatch + + def __repr__(self) -> str: + """ + Information-rich string representation of the BodyMismatch. + """ + return "BodyMismatch({})".format( + ", ".join([ + f"path={self.path!r}", + f"expected={self.expected!r}", + f"actual={self.actual!r}", + f"mismatch={self.mismatch!r}", + ]) + ) + + def __str__(self) -> str: + """ + Informal string representation of the BodyMismatch. + """ + return f"Body mismatch: {self.mismatch}" + + +class MetadataMismatch(Mismatch): + """ + Mismatch due to incorrect message metadata. + """ + + def __init__(self, key: str, expected: str, actual: str, mismatch: str) -> None: + """ + Initialise a new MetadataMismatch. + + Args: + key: + Metadata key. + + expected: + Expected value. + + actual: + Actual value. + + mismatch: + Description of the mismatch. + """ + self._key = key + self._expected = expected + self._actual = actual + self._mismatch = mismatch + + @property + def type(self) -> str: + """ + Type of the mismatch. + """ + return "MetadataMismatch" + + @property + def key(self) -> str: + """ + Metadata key. + """ + return self._key + + @property + def expected(self) -> str: + """ + Expected value. + """ + return self._expected + + @property + def actual(self) -> str: + """ + Actual value. + """ + return self._actual + + @property + def mismatch(self) -> str: + """ + Description of the mismatch. + """ + return self._mismatch + + def __repr__(self) -> str: + """ + Information-rich string representation of the MetadataMismatch. + """ + return "MetadataMismatch({})".format( + ", ".join([ + f"key={self.key!r}", + f"expected={self.expected!r}", + f"actual={self.actual!r}", + f"mismatch={self.mismatch!r}", + ]) + ) + + def __str__(self) -> str: + """ + Informal string representation of the MetadataMismatch. + """ + return ( + "Metadata mismatch: " + f"{self.key}: expected {self.expected}, got {self.actual} " + f"({self.mismatch})" + ) + + +class MismatchesError(PactError): + """ + Exception raised when there are mismatches between the Pact and the server. + """ + + def __init__(self, *mismatches: Mismatch | dict[str, Any]) -> None: + """ + Initialise a new MismatchesError. + + Args: + mismatches: + Mismatches between the Pact and the server. + """ + super().__init__(f"Mismatched interaction (count: {len(mismatches)})") + self._mismatches = [ + m if isinstance(m, Mismatch) else Mismatch.from_dict(m) for m in mismatches + ] + + @property + def mismatches(self) -> list[Mismatch]: + """ + Mismatches between the Pact and the server. + """ + return self._mismatches + + def __repr__(self) -> str: + """ + Information-rich string representation of the MismatchesError. + """ + return "MismatchesError({})".format( + ", ".join(f"{m!r}" for m in self._mismatches) + ) + + def __str__(self) -> str: + """ + Informal string representation of the MismatchesError. + """ + return "\n".join([ + "Mismatches:", + *(f" ({i + 1}) {m}" for i, m in enumerate(self._mismatches)), + ]) diff --git a/src/pact/generate/__init__.py b/src/pact/generate/__init__.py new file mode 100644 index 000000000..58f910d86 --- /dev/null +++ b/src/pact/generate/__init__.py @@ -0,0 +1,518 @@ +r""" +Generator functionality. + +This module provides flexible value generators for use in Pact contracts. +Generators allow you to specify dynamic values for contract testing, ensuring +that your tests remain robust and non-deterministic where appropriate. These +generators are typically used in conjunction with matchers to produce example +data for consumer-driven contract tests. + +Generators are essential for producing dynamic values in contract tests, such as +random integers, dates, UUIDs, and more. This helps ensure that your contracts +are resilient to changes and do not rely on hardcoded values, which can lead to +brittle tests. + +/// warning +Do not import functions directly from `pact.generate` to avoid shadowing Python +built-in types. Instead, import the `generate` module and use its functions as +`generate.int`, `generate.str`, etc. + +```python +# Recommended +from pact import generate + +generate.int(...) + +# Not recommended +from pact.generate import int + +int(...) +``` +/// + + +Many functions in this module are named after the type they generate (e.g., +`int`, `str`, `bool`). Importing directly from this module may shadow Python +built-in types, so always use the `generate` module. + +Generators are typically used in conjunction with matchers, which allow Pact to +validate values during contract tests. If a `value` is not provided within a +matcher, a generator will produce a random value that conforms to the specified +constraints. + +## Basic Types + +Generate random values for basic types: + +```python +from pact import generate + +random_bool = generate.bool() +random_int = generate.int(min=0, max=100) +random_float = generate.float(precision=2) +random_str = generate.str(size=12) +``` + +## Dates, Times, and UUIDs + +Produce values in specific formats: + +```python +random_date = generate.date(format="%Y-%m-%d") +random_time = generate.time(format="%H:%M:%S") +random_datetime = generate.datetime(format="%Y-%m-%dT%H:%M:%S%z") +random_uuid = generate.uuid(format="lowercase") +``` + +## Regex and Hexadecimal + +Generate values matching a pattern or hexadecimal format: + +```python +random_code = generate.regex(r"[A-Z]{3}-\d{4}") +random_hex = generate.hex(digits=8) +``` + +### Provider State and Mock Server URLs + +For advanced contract scenarios: + +```python +provider_value = generate.provider_state(expression="user_id") +mock_url = generate.mock_server_url(regex=r"http://localhost:\d+") +``` + +For more details and advanced usage, see the documentation for each function +below. +""" + +from __future__ import annotations + +import builtins +import warnings +from typing import TYPE_CHECKING, Literal + +from pact._util import strftime_to_simple_date_format +from pact.generate.generator import ( + AbstractGenerator, + GenericGenerator, +) + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from types import ModuleType + +# ruff: noqa: A001 +# We provide a more 'Pythonic' interface by matching the names of the +# functions to the types they generate (e.g., `generate.int` generates +# integers). This overrides the built-in types which are accessed via the +# `builtins` module. +# ruff: noqa: A002 +# We only for overrides of built-ins like `min`, `max` and `type` as +# arguments to provide a nicer interface for the user. + + +# The Pact specification allows for arbitrary generators to be defined; however +# in practice, only the matchers provided by the FFI are used and supported. +# +# +__all__ = [ + "AbstractGenerator", + "bool", + "boolean", + "date", + "datetime", + "decimal", + "float", + "hex", + "hexadecimal", + "int", + "integer", + "mock_server_url", + "provider_state", + "regex", + "str", + "string", + "time", + "timestamp", + "uuid", +] + +# We prevent users from importing from this module to avoid shadowing built-ins. +__builtins_import = builtins.__import__ + + +def __import__( # noqa: N807 + name: builtins.str, + globals: Mapping[builtins.str, object] | None = None, + locals: Mapping[builtins.str, object] | None = None, + fromlist: Sequence[builtins.str] | None = None, + level: builtins.int = 0, +) -> ModuleType: + """ + Override to warn when importing functions directly from this module. + + This function is used to override the built-in `__import__` function to + warn users when they import functions directly from this module. This is + done to avoid shadowing built-in types and functions. + """ + __tracebackhide__ = True + fromlist = fromlist or () + if name == "pact.generate" and len(set(fromlist) - {"AbstractGenerator"}) > 0: + warnings.warn( + "Avoid `from pact.generate import `. " + "Prefer importing `generate` and use `generate.`", + stacklevel=2, + ) + return __builtins_import(name, globals, locals, fromlist, level) + + +builtins.__import__ = __import__ + + +def int( + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> AbstractGenerator: + """ + Generate a random integer. + + Args: + min: + Minimum value for the integer. + + max: + Maximum value for the integer. + + Returns: + Generator producing random integers. + """ + params: dict[builtins.str, builtins.int] = {} + if min is not None: + params["min"] = min + if max is not None: + params["max"] = max + return GenericGenerator("RandomInt", extra_fields=params) + + +def integer( + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> AbstractGenerator: + """ + Alias for [`generate.int`][int]. + + Args: + min: + Minimum value for the integer. + + max: + Maximum value for the integer. + + Returns: + Generator producing random integers. + """ + return int(min=min, max=max) + + +def float(precision: builtins.int | None = None) -> AbstractGenerator: + """ + Generate a random decimal number. + + Precision refers to the total number of digits (excluding leading zeros), + not decimal places. For example, precision of 3 may yield `0.123` or `12.3`. + + Args: + precision: + Number of digits to generate. + + Returns: + Generator producing random decimal values. + """ + params: dict[builtins.str, builtins.int] = {} + if precision is not None: + params["digits"] = precision + return GenericGenerator("RandomDecimal", extra_fields=params) + + +def decimal(precision: builtins.int | None = None) -> AbstractGenerator: + """ + Alias for [`generate.float`][float]. + + Args: + precision: + Number of digits to generate. + + Returns: + Generator producing random decimal values. + """ + return float(precision=precision) + + +def hex(digits: builtins.int | None = None) -> AbstractGenerator: + """ + Generate a random hexadecimal value. + + Args: + digits: + Number of digits to generate. + + Returns: + Generator producing random hexadecimal values. + """ + params: dict[builtins.str, builtins.int] = {} + if digits is not None: + params["digits"] = digits + return GenericGenerator("RandomHexadecimal", extra_fields=params) + + +def hexadecimal(digits: builtins.int | None = None) -> AbstractGenerator: + """ + Alias for [`generate.hex`][hex]. + + Args: + digits: + Number of digits to generate. + + Returns: + Generator producing random hexadecimal values. + """ + return hex(digits=digits) + + +def str(size: builtins.int | None = None) -> AbstractGenerator: + """ + Generate a random string. + + Args: + size: + Size of the string to generate. + + Returns: + Generator producing random strings. + """ + params: dict[builtins.str, builtins.int] = {} + if size is not None: + params["size"] = size + return GenericGenerator("RandomString", extra_fields=params) + + +def string(size: builtins.int | None = None) -> AbstractGenerator: + """ + Alias for [`generate.str`][str]. + + Args: + size: + Size of the string to generate. + + Returns: + Generator producing random strings. + """ + return str(size=size) + + +def regex(regex: builtins.str) -> AbstractGenerator: + """ + Generate a string matching a regex pattern. + + Args: + regex: + Regex pattern to match. + + Returns: + Generator producing strings matching the pattern. + """ + return GenericGenerator("Regex", {"regex": regex}) + + +_UUID_FORMAT_NAMES = Literal["simple", "lowercase", "uppercase", "urn"] +_UUID_FORMATS: dict[_UUID_FORMAT_NAMES, builtins.str] = { + "simple": "simple", + "lowercase": "lower-case-hyphenated", + "uppercase": "upper-case-hyphenated", + "urn": "URN", +} + + +def uuid( + format: _UUID_FORMAT_NAMES = "lowercase", +) -> AbstractGenerator: + """ + Generate a UUID. + + Args: + format: + Format of the UUID to generate. Only supported under the V4 specification. + + Returns: + Generator producing UUIDs in the specified format. + """ + return GenericGenerator("Uuid", {"format": format}) + + +def date( + format: builtins.str = "%Y-%m-%d", + *, + disable_conversion: builtins.bool = False, +) -> AbstractGenerator: + """ + Generate a date value. + + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for Pact compatibility. + + Args: + format: + Expected format of the date. Defaults to ISO 8601: `%Y-%m-%d`. + + disable_conversion: + If True, disables conversion from Python's format to Java's format. + The value must then be a Java format string. + + Returns: + Generator producing dates in the specified format. + """ + if not disable_conversion: + format = strftime_to_simple_date_format(format or "%Y-%m-%d") + return GenericGenerator("Date", {"format": format or "%yyyy-MM-dd"}) + + +def time( + format: builtins.str = "%H:%M:%S", + *, + disable_conversion: builtins.bool = False, +) -> AbstractGenerator: + """ + Generate a time value. + + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + + Args: + format: + Expected format of the time. Defaults to ISO 8601: `%H:%M:%S`. + + disable_conversion: + If True, disables conversion from Python's format to Java's format. + The value must then be a Java format string. + + Returns: + Generator producing times in the specified format. + """ + if not disable_conversion: + format = strftime_to_simple_date_format(format or "%H:%M:%S") + return GenericGenerator("Time", {"format": format or "HH:mm:ss"}) + + +def datetime( + format: builtins.str, + *, + disable_conversion: builtins.bool = False, +) -> AbstractGenerator: + """ + Generate a datetime value. + + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for Pact compatibility. + + Args: + format: + Expected format of the timestamp. Defaults to ISO 8601: + `%Y-%m-%dT%H:%M:%S%z`. + + disable_conversion: + If True, disables conversion from Python's format to Java's format. + The value must then be a Java format string. + + Returns: + Generator producing datetimes in the specified format. + """ + if not disable_conversion: + format = strftime_to_simple_date_format(format or "%Y-%m-%dT%H:%M:%S%z") + return GenericGenerator("DateTime", {"format": format or "yyyy-MM-dd'T'HH:mm:ssZ"}) + + +def timestamp( + format: builtins.str, + *, + disable_conversion: builtins.bool = False, +) -> AbstractGenerator: + """ + Alias for [`generate.datetime`][datetime]. + + Returns: + Generator producing datetimes in the specified format. + """ + return datetime(format=format, disable_conversion=disable_conversion) + + +def bool() -> AbstractGenerator: + """ + Generate a random boolean value. + + Returns: + Generator producing random boolean values. + """ + return GenericGenerator("RandomBoolean") + + +def boolean() -> AbstractGenerator: + """ + Alias for [`generate.bool`][bool]. + + Returns: + Generator producing random boolean values. + """ + return bool() + + +def provider_state(expression: builtins.str | None = None) -> AbstractGenerator: + """ + Generate a value from provider state context. + + Args: + expression: + Expression to look up provider state. + + Returns: + Generator producing values from provider state context. + """ + params: dict[builtins.str, builtins.str] = {} + if expression is not None: + params["expression"] = expression + return GenericGenerator("ProviderState", extra_fields=params) + + +def mock_server_url( + regex: builtins.str | None = None, + example: builtins.str | None = None, +) -> AbstractGenerator: + """ + Generate a mock server URL. + + Args: + regex: + Regex pattern to match. + + example: + Example URL to use. + + Returns: + Generator producing mock server URLs. + """ + params: dict[builtins.str, builtins.str] = {} + if regex is not None: + params["regex"] = regex + if example is not None: + params["example"] = example + return GenericGenerator("MockServerURL", extra_fields=params) diff --git a/src/pact/generate/generator.py b/src/pact/generate/generator.py new file mode 100644 index 000000000..f0cd22df3 --- /dev/null +++ b/src/pact/generate/generator.py @@ -0,0 +1,124 @@ +""" +Implementations of generators for the V3 and V4 specifications. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from itertools import chain +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + + from pact.types import GeneratorType + + +class AbstractGenerator(ABC): + """ + Abstract generator. + + In Pact, a generator is used by Pact to generate data on-the-fly during the + contract verification process. Generators are used in combination with + matchers to provide more flexible matching of data. + + This class is abstract and should not be used directly. Instead, use one of + the concrete generator classes. Alternatively, you can create your own + generator by subclassing this class + + The matcher provides methods to convert into an integration JSON object and + a matching rule. These methods are used internally by the Pact library when + communicating with the FFI and generating the Pact file. + """ + + @abstractmethod + def to_integration_json(self) -> dict[str, Any]: + """ + Convert the generator to an integration JSON object. + + See + [`AbstractGenerator.to_integration_json`][AbstractGenerator.to_integration_json] + for more information. + """ + + @abstractmethod + def to_generator_json(self) -> dict[str, Any]: + """ + Convert the generator to a generator JSON object. + + This method is used internally to convert the generator to a JSON object + which can be embedded directly in a number of places in the Pact FFI. + + For more information about this format, refer to the [Pact + specification](https://github.com/pact-foundation/pact-specification/tree/version-4) + and the [matchers + section](https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers) + + Returns: + The generator as a generator JSON object. + """ + + +class GenericGenerator(AbstractGenerator): + """ + Generic generator. + + A generic generator, with the ability to specify the generator type and + additional configuration elements. + """ + + def __init__( + self, + type: GeneratorType, # noqa: A002 + /, + extra_fields: Mapping[str, Any] | None = None, + **kwargs: Any, # noqa: ANN401 + ) -> None: + """ + Instantiate the generator class. + + Args: + type: + The type of the generator. + + extra_fields: + Additional configuration elements to pass to the generator. + These fields will be used when converting the generator to both an + integration JSON object and a generator JSON object. + + **kwargs: + Alternative way to pass additional configuration elements to the + generator. See the `extra_fields` argument for more information. + """ + self.type = type + """ + The type of the generator. + """ + + self._extra_fields = dict(chain((extra_fields or {}).items(), kwargs.items())) + + def to_integration_json(self) -> dict[str, Any]: + """ + Convert the generator to an integration JSON object. + + See + [`AbstractGenerator.to_integration_json`][AbstractGenerator.to_integration_json] + for more information. + """ + return { + "pact:generator:type": self.type, + **self._extra_fields, + } + + def to_generator_json(self) -> dict[str, Any]: + """ + Convert the generator to a generator JSON object. + + See + [`AbstractGenerator.to_generator_json`][AbstractGenerator.to_generator_json] + for more information. + """ + return { + "type": self.type, + **self._extra_fields, + } diff --git a/src/pact/interaction/__init__.py b/src/pact/interaction/__init__.py new file mode 100644 index 000000000..83954346d --- /dev/null +++ b/src/pact/interaction/__init__.py @@ -0,0 +1,85 @@ +""" +Interaction module. + +This module defines the classes that are used to define individual interactions +within a [`Pact`][pact.Pact] between a consumer and a provider. These +interactions can be of different types, such as HTTP requests, synchronous +messages, or asynchronous messages. + +An interaction is a specific request that the consumer makes to the provider, +and the response that the provider should return. On the consumer side, the +interaction clearly defines the request that the consumer will make to the +provider and the response that the consumer expects to receive. On the provider +side, the interaction is replayed to the provider to ensure that the provider is +able to handle the request and return the expected response. + +## Best Practices + +When defining an interaction, it is important to ensure that the interaction is +as minimal as possible (which is in contrast to the way specifications like +OpenAPI are often written). This is because the interaction is used to verify +that the consumer and provider can communicate correctly, not to define the +entire API. + +For example, consider a simple user API that has a `GET /user/{id}` endpoint +which returns an object of the form: + +```json +{ + "id": 123, + "username": "Alice" + "email": "alice@example.com", + "registered": "2021-02-26T10:17:51+11:00", + "last_login": "2024-07-04T13:25:45+10:00" +} +``` + +The user client might have two functionalities: + +1. To check if the user exists, and +2. To retrieve the user's username. + +The implementation of these two would be: + +```python +from pact import Pact + + +pact = Pact(consumer="UserClient", provider="UserService") + +# Checking if a user exists +( + pact.upon_receiving("A request to check if a user exists") + .given("A user with ID 123 exists") + .with_request("GET", "/user/123") + .will_respond_with(200) +) + +# Getting a user's username +( + pact.upon_receiving("A request to get a user's username") + .given("A user with ID 123 exists") + .with_request("GET", "/user/123") + .will_respond_with(200) + .with_body({"username": "Alice"}) +) +``` + +Importantly, even if the server returns more information than just the username, +since the client does not care about this information, it should not be included +in the interaction. +""" + +from __future__ import annotations + +from pact.interaction._async_message_interaction import AsyncMessageInteraction +from pact.interaction._base import Interaction +from pact.interaction._http_interaction import HttpInteraction +from pact.interaction._sync_message_interaction import SyncMessageInteraction + +__all__ = [ + "AsyncMessageInteraction", + "HttpInteraction", + "Interaction", + "SyncMessageInteraction", +] diff --git a/src/pact/interaction/_async_message_interaction.py b/src/pact/interaction/_async_message_interaction.py new file mode 100644 index 000000000..1595c32f7 --- /dev/null +++ b/src/pact/interaction/_async_message_interaction.py @@ -0,0 +1,63 @@ +""" +Asynchronous message interaction. +""" + +from __future__ import annotations + +import pact_ffi +from pact.interaction._base import Interaction + + +class AsyncMessageInteraction(Interaction): + """ + An asynchronous message interaction. + + This class defines an asynchronous message interaction between a consumer + and a provider. It defines the kind of messages a consumer can accept, and + the is agnostic of the underlying protocol, be it a message queue, Apache + Kafka, or some other asynchronous protocol. + """ + + def __init__(self, pact_handle: pact_ffi.PactHandle, description: str) -> None: + """ + Initialise a new Asynchronous Message Interaction. + + This function should not be called directly. Instead, an + AsyncMessageInteraction should be created using the + [`upon_receiving`][pact.Pact.upon_receiving] method of a + [`Pact`][pact.Pact] instance using the `"Async"` interaction type. + + Args: + pact_handle: + The Pact instance this interaction belongs to. + + description: + Description of the interaction. This must be unique within the + Pact. + """ + super().__init__(description) + self.__handle = pact_ffi.new_message_interaction(pact_handle, description) + + @property + def _handle(self) -> pact_ffi.InteractionHandle: + """ + Handle for the Interaction. + + This is used internally by the library to pass the Interaction to the + underlying Pact library. + """ + return self.__handle + + @property + def _interaction_part(self) -> pact_ffi.InteractionPart: + """ + Interaction part. + + Where interactions have multiple parts, this property keeps track + of which part is currently being set. + + As this is an asynchronous message interaction, this will always + return a [`REQUEST`][pact_ffi.InteractionPart.REQUEST], as there the + consumer of the message does not send any responses. + """ + return pact_ffi.InteractionPart.REQUEST diff --git a/src/pact/interaction/_base.py b/src/pact/interaction/_base.py new file mode 100644 index 000000000..fc1dbc137 --- /dev/null +++ b/src/pact/interaction/_base.py @@ -0,0 +1,648 @@ +""" +Pact between a consumer and a provider. + +This module defines the classes that are used to define a Pact between a +consumer and a provider. It defines the interactions between the two parties, +and provides the functionality to verify that the interactions are satisfied. + +For the roles of consumer and provider, see the documentation for the +`pact.service` module. +""" + +from __future__ import annotations + +import abc +import json +from typing import TYPE_CHECKING, Any, Literal + +import pact_ffi +from pact.match.matcher import IntegrationJSONEncoder, MatchingRuleJSONEncoder + +if TYPE_CHECKING: + from pathlib import Path + + from typing_extensions import Self + + from pact.match import AbstractMatcher + + +class Interaction(abc.ABC): + """ + Interaction between a consumer and a provider. + + This abstract class defines an interaction between a consumer and a + provider. The concrete subclasses define the type of interaction, and + include: + + - [`HttpInteraction`][HttpInteraction] + - [`AsyncMessageInteraction`][AsyncMessageInteraction] + - [`SyncMessageInteraction`][SyncMessageInteraction] + + # Interaction Part + + For HTTP and synchronous message interactions, the interaction is split into + two parts: the request and the response. The interaction part is used to + specify which part of the interaction is being set. This is specified using + the `part` argument of various methods (which defaults to an intelligent + choice based on the order of the methods called). + + The asynchronous message interaction does not have parts, as the interaction + contains a single message from the provider (a.ka. the producer of the + message) to the consumer. An attempt to set a response part will raise an + error. + """ + + def __init__(self, description: str) -> None: + """ + Create a new Interaction. + + As this class is abstract, this function should not be called directly + but should instead be called through one of the concrete subclasses. + + Args: + description: + Description of the interaction. This must be unique within the + Pact. + """ + self._description = description + + def __str__(self) -> str: + """ + Nice representation of the Interaction. + """ + return f"{self.__class__.__name__}({self._description})" + + def __repr__(self) -> str: + """ + Debugging representation of the Interaction. + """ + return f"{self.__class__.__name__}({self._handle!r})" + + @property + @abc.abstractmethod + def _handle(self) -> pact_ffi.InteractionHandle: + """ + Handle for the Interaction. + + This is used internally by the library to pass the Interaction to the + underlying Pact library. + """ + + @property + @abc.abstractmethod + def _interaction_part(self) -> pact_ffi.InteractionPart: + """ + Interaction part. + + Where interactions have multiple parts, this property keeps track + of which part is currently being set. + """ + + def _parse_interaction_part( + self, + part: Literal["Request", "Response"] | None, + ) -> pact_ffi.InteractionPart: + """ + Convert the input into an InteractionPart. + """ + if part == "Request": + return pact_ffi.InteractionPart.REQUEST + if part == "Response": + return pact_ffi.InteractionPart.RESPONSE + if part is None: + return self._interaction_part + msg = f"Invalid part: {part}" + raise ValueError(msg) + + def given( + self, + state: str, + parameters: dict[str, object] | None = None, + /, + **kwargs: object, + ) -> Self: + """ + Set the provider state. + + This is the state that the provider should be in when the Interaction is + executed. When the provider is being verified, the provider state is + passed to the provider so that its internal state can be set to match + the provider state. + + In its simplest form, the provider state is a string. For example, to + match a provider state of `a user exists`, you would use: + + ```python + pact.upon_receiving("a request").given("a user exists") + ``` + + In many circumstances, it is useful to parameterize the state with + additional data. In the example above, this could be with: + + ```python + pact.upon_receiving("a request").given( + "a user exists", + id=123, + name="Alice", + ) + ``` + + This function can be called repeatedly to specify multiple provider + states for the same Interaction. This allows for the same provider state + to be reused with different parameters: + + ```python + ( + pact + .upon_receiving("a request") + .given("a user exists", id=123, name="Alice") + .given("a user exists", id=456, name="Bob") + ) + ``` + + Args: + state: + Provider state for the Interaction. + + parameters: + Should some of the parameters not be valid Python key + identifiers, a dictionary can be passed in as the second + positional argument. + + ```python + pact.upon_receiving("A user request").given( + "The given user exists", + {"user-id": 123}, + ) + ``` + + These parameters are merged with any additional keyword + arguments passed to the function. + + kwargs: + The additional parameters for the provider state, specified as + additional arguments to the function. The values must be + serializable using Python's [`json.dumps`][json.dumps] + function. + + These parameters are merged with any parameters passed in the + `parameters` positional argument. + """ + if not parameters and not kwargs: + pact_ffi.given(self._handle, state) + else: + pact_ffi.given_with_params( + self._handle, + state, + json.dumps({**(parameters or {}), **kwargs}), + ) + + return self + + def with_body( + self, + body: str | dict[str, Any] | AbstractMatcher[Any] | None = None, + content_type: str | None = None, + part: Literal["Request", "Response"] | None = None, + ) -> Self: + """ + Set the body of the request or response. + + Args: + body: + Body of the request. If this is `None`, then the body is + empty. + + content_type: + Content type of the body. This is ignored if the `Content-Type` + header has already been set. + + part: + Whether the body should be added to the request or the response. + If `None`, then the function intelligently determines whether + the body should be added to the request or the response. + """ + if body and isinstance(body, str): + body_str = body + else: + body_str = json.dumps(body, cls=IntegrationJSONEncoder) + + pact_ffi.with_body( + self._handle, + self._parse_interaction_part(part), + content_type, + body_str, + ) + return self + + def with_binary_body( + self, + body: bytes | None, + content_type: str | None = None, + part: Literal["Request", "Response"] | None = None, + ) -> Self: + """ + Adds a binary body to the request or response. + + Note that for HTTP interactions, this function will overwrite the body + if it has been set using [`with_body`][with_body]. + + Args: + body: + Body of the request. + + content_type: + Content type of the body. This is ignored if the `Content-Type` + header has already been set. + + part: + Whether the body should be added to the request or the response. + If `None`, then the function intelligently determines whether + the body should be added to the request or the response. + """ + pact_ffi.with_binary_body( + self._handle, + self._parse_interaction_part(part), + content_type, + body, + ) + return self + + def with_metadata( + self, + metadata: dict[str, object | AbstractMatcher[object]] | None = None, + part: Literal["Request", "Response"] | None = None, + /, + **kwargs: object | AbstractMatcher[object], + ) -> Self: + """ + Add metadata for the interaction. + + This function may either be called with a single dictionary of metadata, + or with keyword arguments that are the key-value pairs of the metadata + (or a combination thereof): + + ```python + interaction.with_metadata({"foo": "bar", "baz": "qux"}) + interaction.with_metadata(foo="bar", baz="qux") + ``` + + The value of `None` will remove the metadata key from the interaction. + This is distinct from using an empty string or a string containing the + JSON `null` value, which will set the metadata key to an empty string or + the JSON `null` value, respectively. + + The values must be serializable to JSON using [`json.dumps`][json.dumps] + and may contain matchers and generators. If you wish to use a valid + JSON-encoded string as a metadata value, prefer the + [`set_metadata`][set_metadata] method as this does not perform any + additional parsing of the string. + + Args: + metadata: + Dictionary of metadata keys and associated values. + + part: + Whether the metadata should be added to the request or the + response. If `None`, then the function intelligently determines + whether the body should be added to the request or the response. + + **kwargs: + Additional metadata key-value pairs. + + Returns: + The current instance of the interaction. + """ + interaction_part = self._parse_interaction_part(part) + for k, v in (metadata or {}).items(): + pact_ffi.with_metadata( + self._handle, + k, + json.dumps(v, cls=IntegrationJSONEncoder), + interaction_part, + ) + for k, v in kwargs.items(): + pact_ffi.with_metadata( + self._handle, + k, + json.dumps(v, cls=IntegrationJSONEncoder), + interaction_part, + ) + return self + + def set_metadata( + self, + metadata: dict[str, str] | None = None, + part: Literal["Request", "Response"] | None = None, + /, + **kwargs: str, + ) -> Self: + """ + Add metadata for the interaction. + + This function behaves exactly like [`with_metadata`][with_metadata] but + does not perform any parsing of the value strings. The strings must be + valid JSON-encoded strings. + + The value of `None` will remove the metadata key from the interaction. + This is distinct from using an empty string or a string containing the + JSON `null` value, which will set the metadata key to an empty string + or the JSON `null` value, respectively. + + Args: + metadata: + Dictionary of metadata keys and associated values. + + part: + Whether the metadata should be added to the request or the + response. If `None`, then the function intelligently determines + whether the body should be added to the request or the response. + + **kwargs: + Additional metadata key-value pairs. + + Returns: + The current instance of the interaction. + """ + interaction_part = self._parse_interaction_part(part) + for k, v in (metadata or {}).items(): + pact_ffi.with_metadata(self._handle, k, v, interaction_part) + for k, v in kwargs.items(): + pact_ffi.with_metadata(self._handle, k, v, interaction_part) + return self + + def with_multipart_file( + self, + part_name: str, + path: Path | None, + content_type: str | None = None, + part: Literal["Request", "Response"] | None = None, + boundary: str | None = None, + ) -> Self: + """ + Adds a binary file as the body of a multipart request or response. + + Args: + part_name: + Name of the multipart part. + + path: + Path to the file to add. + + content_type: + Content type of the part. + + part: + Whether the part should be added to the request or the + response. + If `None`, then the function intelligently determines whether + the part should be added to the request or the response. + + boundary: + Boundary string for the multipart message. + """ + pact_ffi.with_multipart_file_v2( + self._handle, + self._parse_interaction_part(part), + content_type, + path, + part_name, + boundary, + ) + return self + + def set_key(self, key: str | None) -> Self: + """ + Sets the key for the interaction. + + This is used by V4 interactions to set the key of the interaction, which + can subsequently used to reference the interaction. + """ + pact_ffi.set_key(self._handle, key) + return self + + def set_pending(self, *, pending: bool) -> Self: + """ + Mark the interaction as pending. + + This is used by V4 interactions to mark the interaction as pending, in + which case the provider is not expected to honour the interaction. + """ + pact_ffi.set_pending(self._handle, pending=pending) + return self + + def set_comment(self, key: str, value: Any | None) -> Self: # noqa: ANN401 + """ + Set a comment for the interaction. + + This is used by V4 interactions to set a comment for the interaction. A + comment consists of a key-value pair, where the key is a string and the + value is anything that can be encoded as JSON. + + Args: + key: + Key for the comment. + + value: + Value for the comment. This must be encodable using + [`json.dumps`][json.dumps], or an existing JSON string. The + value of `None` will remove the comment with the given key. + + # Warning + + This function will overwrite any existing comment with the same key. In + particular, the `text` key is used by + [`add_text_comment`][add_text_comment]. + """ + if isinstance(value, str) or value is None: + pact_ffi.set_comment(self._handle, key, value) + else: + pact_ffi.set_comment(self._handle, key, json.dumps(value)) + return self + + def add_text_comment(self, comment: str) -> Self: + """ + Add a text comment for the interaction. + + This is used by V4 interactions to set arbitrary text comments for the + interaction. + + Args: + comment: + Text of the comment. + + # Warning + + Internally, the comments are appended to an array under the `text` + comment key. Care should be taken to ensure that conflicts are not + introduced by [`set_comment`][set_comment]. + """ + pact_ffi.add_text_comment(self._handle, comment) + return self + + def add_external_reference( + self, + group: str, + name: str, + value: str, + ) -> Self: + """ + Add an external reference to the interaction. + + This is used by V4 interactions to record references to external + resources, such as tickets or pull requests, against the interaction. + References are stored under `comments.references[group][name]` in the + generated Pact file. + + This method may be called multiple times to add references to multiple + external systems. Calling it with the same `group` and `name` will + overwrite the previous value. + + Args: + group: + Group or system the reference belongs to (e.g. `"Jira"`, + `"OpenAPI"`, `"GitHub"`). + + name: + Name or identifier of the reference (e.g. `"TICKET"`, + `"OperationID"`, `"PullRequest"`). + + value: + Value of the reference, typically an ID (e.g., `"TICKET-123"`, + `"getUserById"`, `"#123"`). + + Example: + ```python + ( + pact + .upon_receiving("a request") + .add_external_reference( + "Jira", + "TICKET-123", + "https://jira.example.com/browse/TICKET-123", + ) + .with_request("GET", "/users/123") + .will_respond_with(200) + ) + ``` + """ + pact_ffi.add_interaction_reference(self._handle, group, name, value) + return self + + def test_name( + self, + name: str, + ) -> Self: + """ + Set the test name annotation for the interaction. + + This is used by V4 interactions to set the name of the test. + + Args: + name: + Name of the test. + """ + pact_ffi.interaction_test_name(self._handle, name) + return self + + def with_plugin_contents( + self, + contents: dict[str, Any] | str, + content_type: str, + part: Literal["Request", "Response"] | None = None, + ) -> Self: + """ + Set the interaction content using a plugin. + + The value of `contents` is passed directly to the plugin as a JSON + string. The plugin will document the format of the JSON content. + + Args: + contents: + Body of the request. If this is `None`, then the body is empty. + + content_type: + Content type of the body. This is ignored if the `Content-Type` + header has already been set. + + part: + Whether the body should be added to the request or the response. + If `None`, then the function intelligently determines whether + the body should be added to the request or the response. + """ + if isinstance(contents, dict): + contents = json.dumps(contents) + + pact_ffi.interaction_contents( + self._handle, + self._parse_interaction_part(part), + content_type, + contents, + ) + return self + + def with_matching_rules( + self, + rules: dict[str, Any] | str, + part: Literal["Request", "Response"] | None = None, + ) -> Self: + """ + Add matching rules to the interaction. + + Matching rules are used to specify how the request or response should be + matched. This is useful for specifying that certain parts of the request + or response are flexible, such as the date or time. + + Args: + rules: + Matching rules to add to the interaction. This must be encodable + using [`json.dumps`][json.dumps], or a string. + + part: + Whether the matching rules should be added to the request or the + response. If `None`, then the function intelligently determines + whether the matching rules should be added to the request or the + response. + """ + if isinstance(rules, dict): + rules = json.dumps(rules, cls=MatchingRuleJSONEncoder) + + pact_ffi.with_matching_rules( + self._handle, + self._parse_interaction_part(part), + rules, + ) + return self + + def with_generators( + self, + generators: dict[str, Any] | str, + part: Literal["Request", "Response"] | None = None, + ) -> Self: + """ + Add generators to the interaction. + + Generators are used to adjust how parts of the request or response are + generated when the Pact is being tested. This can be useful for fields + that vary each time the request is made, such as a timestamp. + + Args: + generators: + Generators to add to the interaction. This must be encodable + using [`json.dumps`][json.dumps], or a string. + + part: + Whether the generators should be added to the request or the + response. If `None`, then the function intelligently determines + whether the generators should be added to the request or the + response. + """ + if isinstance(generators, dict): + generators = json.dumps(generators) + + pact_ffi.with_generators( + self._handle, + self._parse_interaction_part(part), + generators, + ) + return self diff --git a/src/pact/interaction/_http_interaction.py b/src/pact/interaction/_http_interaction.py new file mode 100644 index 000000000..b62273dce --- /dev/null +++ b/src/pact/interaction/_http_interaction.py @@ -0,0 +1,403 @@ +""" +HTTP interaction. +""" + +from __future__ import annotations + +import json +from collections import defaultdict +from collections.abc import Mapping +from typing import TYPE_CHECKING, Literal + +import pact_ffi +from pact.interaction._base import Interaction +from pact.match import AbstractMatcher +from pact.match.matcher import IntegrationJSONEncoder + +if TYPE_CHECKING: + from collections.abc import Iterable + + from typing_extensions import Self + + +class HttpInteraction(Interaction): + """ + A synchronous HTTP interaction. + + This class defines a synchronous HTTP interaction between a consumer and a + provider. It defines a specific request that the consumer makes to the + provider, and the response that the provider should return. + + This class provides a simple way to define the request and response for an + HTTP interaction. As many elements are shared between the request and + response, this class provides a common interface for both. The functions + intelligently determine whether the element should be added to the request + or the response based on whether [`will_respond_with`][will_respond_with] + has been called. + + For example, the following two interactions are equivalent: + + ```python + ( + pact + .upon_receiving("a request") + .with_request("GET", "/") + .with_header("X-Foo", "bar") + .will_respond_with(200) + .with_header("X-Hello", "world") + ) + ``` + + ```python + ( + pact + .upon_receiving("a request") + .with_request("GET", "/") + .will_respond_with(200) + .with_header("X-Foo", "bar", part="Request") + .with_header("X-Hello", "world", part="Response") + ) + ``` + """ + + def __init__(self, pact_handle: pact_ffi.PactHandle, description: str) -> None: + """ + Initialise a new HTTP Interaction. + + This class should not be instantiated directly. Instead, an + `HttpInteraction` should be created using the + [`upon_receiving`][pact.Pact.upon_receiving] method of a + [`Pact`][pact.Pact] instance. + """ + super().__init__(description) + self.__handle = pact_ffi.new_interaction(pact_handle, description) + self.__interaction_part = pact_ffi.InteractionPart.REQUEST + self._request_indices: dict[ + tuple[pact_ffi.InteractionPart, str], + int, + ] = defaultdict(int) + self._parameter_indices: dict[str, int] = defaultdict(int) + + @property + def _handle(self) -> pact_ffi.InteractionHandle: + """ + Handle for the Interaction. + + This is used internally by the library to pass the Interaction to the + underlying Pact library. + """ + return self.__handle + + @property + def _interaction_part(self) -> pact_ffi.InteractionPart: + """ + Interaction part. + + Keeps track whether we are setting by default the request or the + response in the HTTP interaction. + """ + return self.__interaction_part + + def with_request(self, method: str, path: str | AbstractMatcher[object]) -> Self: + """ + Set the request. + + This is the request that the consumer will make to the provider. + + Args: + method: + HTTP method for the request. + + path: + Path for the request. + """ + if isinstance(path, AbstractMatcher): + path_str = json.dumps(path, cls=IntegrationJSONEncoder) + else: + path_str = path + pact_ffi.with_request(self._handle, method, path_str) + return self + + def with_header( + self, + name: str, + value: str | dict[str, str] | AbstractMatcher[object], + part: Literal["Request", "Response"] | None = None, + ) -> Self: + r""" + Add a header to the request. + + If the same header has multiple values (see [RFC9110 + §5.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.2)), then + the same header must be specified multiple times with _order being + preserved_. For example + + ```python + ( + pact + .upon_receiving("a request") + .with_header("X-Foo", "bar") + .with_header("X-Foo", "baz") + ) + ``` + + will expect a request with the following headers: + + ```http + X-Foo: bar + X-Foo: baz + # Or, equivalently: + X-Foo: bar, baz + ``` + + Note that repeated headers are _case insensitive_ in accordance with + [RFC 9110 + §5.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1). + + Args: + name: + Name of the header. + + value: + Value of the header. + + part: + Whether the header should be added to the request or the + response. + + If `None`, then the function intelligently determines whether + the header should be added to the request or the response, based + on whether the [`will_respond_with`][will_respond_with] method + has been called. + """ + interaction_part = self._parse_interaction_part(part) + name_lower = name.lower() + index = self._request_indices[(interaction_part, name_lower)] + self._request_indices[(interaction_part, name_lower)] += 1 + if not isinstance(value, str): + value_str: str = json.dumps(value, cls=IntegrationJSONEncoder) + else: + value_str = value + pact_ffi.with_header_v2( + self._handle, + interaction_part, + name, + index, + value_str, + ) + return self + + def with_headers( + self, + headers: dict[str, str] | Iterable[tuple[str, str]], + part: Literal["Request", "Response"] | None = None, + ) -> Self: + """ + Add multiple headers to the request. + + While it is often convenient to use a dictionary to specify headers, + this does not support repeated headers. If you need to specify repeated + headers, consider one of the following: + + - An alternative dictionary implementation which supports repeated + keys such as [multidict](https://pypi.org/project/multidict/). + + - Passing in an iterable of key-value tuples. + + - Make multiple calls to this function or + [`with_header`][with_header]. + + See + [`with_header`][with_header] + for more information. + + Args: + headers: + Headers to add to the request. + + part: + Whether the header should be added to the request or the + response. + + If `None`, then the function intelligently determines whether + the header should be added to the request or the response, based + on whether the [`will_respond_with`][will_respond_with] method + has been called. + """ + if isinstance(headers, dict): + headers = headers.items() + for name, value in headers: + self.with_header(name, value, part) + return self + + def set_header( + self, + name: str, + value: str, + part: Literal["Request", "Response"] | None = None, + ) -> Self: + r""" + Add a header to the request. + + Unlike [`with_header`][with_header], this function does no additional + processing of the header value. This is useful for headers that contain + a JSON object. + + Args: + name: + Name of the header. + + value: + Value of the header. + + part: + Whether the header should be added to the request or the + response. + + If `None`, then the function intelligently determines whether + the header should be added to the request or the response, based + on whether the [`will_respond_with`][will_respond_with] method + has been called. + """ + pact_ffi.set_header( + self._handle, + self._parse_interaction_part(part), + name, + value, + ) + return self + + def set_headers( + self, + headers: dict[str, str] | Iterable[tuple[str, str]], + part: Literal["Request", "Response"] | None = None, + ) -> Self: + """ + Add multiple headers to the request. + + While it is often convenient to use a dictionary to specify headers, + this does not support repeated headers. If you need to specify repeated + headers, consider one of the following: + + - An alternative dictionary implementation which supports repeated + keys such as [multidict](https://pypi.org/project/multidict/). + + - Passing in an iterable of key-value tuples. + + - Make multiple calls to this function or + [`with_header`][with_header]. + + See [`set_header`][set_header] for more information. + + Args: + headers: + Headers to add to the request. + + part: + Whether the headers should be added to the request or the + response. + + If `None`, then the function intelligently determines whether + the headers should be added to the request or the response, + based on whether the [`will_respond_with`][will_respond_with] + method has been called. + """ + if isinstance(headers, dict): + headers = headers.items() + for name, value in headers: + self.set_header(name, value, part) + return self + + def with_query_parameter( + self, + name: str, + value: object | AbstractMatcher[object], + ) -> Self: + r""" + Add a query to the request. + + This is the query parameter that the consumer will send to the provider. + + If the same parameter can support multiple values, then the same + parameter can be specified multiple times: + + ```python + ( + pact + .upon_receiving("a request") + .with_query_parameter("name", "John") + .with_query_parameter("name", "Mary") + ) + ``` + + Args: + name: + Name of the query parameter. + + value: + Value of the query parameter. + """ + index = self._parameter_indices[name] + self._parameter_indices[name] += 1 + if not isinstance(value, str): + value_str: str = json.dumps(value, cls=IntegrationJSONEncoder) + else: + value_str = value + pact_ffi.with_query_parameter_v2( + self._handle, + name, + index, + value_str, + ) + return self + + def with_query_parameters( + self, + parameters: Mapping[str, object | AbstractMatcher[object]] + | Iterable[tuple[str, object | AbstractMatcher[object]]], + ) -> Self: + """ + Add multiple query parameters to the request. + + While it is often convenient to use a dictionary to specify query + parameters, this does not support repeated keys. If you need to specify + repeated keys, consider one of the following: + + - An alternative dictionary implementation which supports repeated + keys such as [multidict](https://pypi.org/project/multidict/). + + - Passing in an iterable of key-value tuples. + + - Make multiple calls to this function or + [`with_query_parameter`][with_query_parameter]. + + See [`with_query_parameter`][with_query_parameter] for more information. + + Args: + parameters: + Query parameters to add to the request. + """ + if isinstance(parameters, Mapping): + parameters = parameters.items() + for name, value in parameters: + self.with_query_parameter(name, value) + return self + + def will_respond_with(self, status: int) -> Self: + """ + Set the response status. + + Ideally, this function is called once all of the request information has + been set. This allows functions such as [`with_header`][with_header] to + intelligently determine whether this is a request or response header. + + Alternatively, the `part` argument can be used to explicitly specify + whether the header should be added to the request or the response. + + Args: + status: + Status for the response. + """ + pact_ffi.response_status(self._handle, status) + self.__interaction_part = pact_ffi.InteractionPart.RESPONSE + return self diff --git a/src/pact/interaction/_sync_message_interaction.py b/src/pact/interaction/_sync_message_interaction.py new file mode 100644 index 000000000..a74c6da90 --- /dev/null +++ b/src/pact/interaction/_sync_message_interaction.py @@ -0,0 +1,92 @@ +""" +Synchronous message interaction. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pact_ffi +from pact.interaction._base import Interaction + +if TYPE_CHECKING: + from typing_extensions import Self + + +class SyncMessageInteraction(Interaction): + """ + A synchronous message interaction. + + This class defines a synchronous message interaction between a consumer and + a provider. As with [`HttpInteraction`][HttpInteraction], it + defines a specific request that the consumer makes to the provider, and the + response that the provider should return. + """ + + def __init__(self, pact_handle: pact_ffi.PactHandle, description: str) -> None: + """ + Initialise a new Synchronous Message Interaction. + + This function should not be called directly. Instead, an + AsyncMessageInteraction should be created using the + [`upon_receiving`][Pact.upon_receiving] method of a + [`Pact`][Pact] instance using the `"Sync"` interaction type. + + Args: + pact_handle: + Handle for the Pact. + + description: + Description of the interaction. This must be unique within the + Pact. + """ + super().__init__(description) + self.__handle = pact_ffi.new_sync_message_interaction( + pact_handle, + description, + ) + self.__interaction_part = pact_ffi.InteractionPart.REQUEST + + @property + def _handle(self) -> pact_ffi.InteractionHandle: + """ + Handle for the Interaction. + + This is used internally by the library to pass the Interaction to the + underlying Pact library. + """ + return self.__handle + + @property + def _interaction_part(self) -> pact_ffi.InteractionPart: + return self.__interaction_part + + def will_respond_with(self) -> Self: + """ + Begin the response part of the interaction. + + This method is a convenience method to separate the request and response + parts of the interaction. This function is analogous to the + [`HttpInteraction.will_respond_with()`][HttpInteraction.will_respond_with] + method, albeit more generic for synchronous message interactions. + + For example, the following two snippets are equivalent: + + ```python + Pact(...).upon_receiving("A sync request", interaction="Sync") + .with_body("request body", part="Request") + .with_body("response body", part="Response") + ``` + + ```python + Pact(...).upon_receiving("A sync request", interaction="Sync") + .with_body("request body") + .will_respond_with() + .with_body("response body") + ``` + + Returns: + The current instance of the interaction. + """ + self.__interaction_part = pact_ffi.InteractionPart.RESPONSE + return self diff --git a/src/pact/match/__init__.py b/src/pact/match/__init__.py new file mode 100644 index 000000000..073ea7a57 --- /dev/null +++ b/src/pact/match/__init__.py @@ -0,0 +1,1124 @@ +r""" +Matching functionality. + +This module defines flexible matching rules for use in Pact contracts. These +rules specify the expected content of exchanged data, allowing for more robust +contract testing than simple equality checks. + +For example, a contract may specify how a new record is created via a POST +request. The consumer defines the data to send and the expected response. The +response may include additional fields from the provider, such as an ID or +creation timestamp. The contract can require the ID to match a specific format +(e.g., integer or UUID) and the timestamp to be ISO 8601. + +/// warning +Do not import functions directly from this module. Instead, import the `match` +module and use its functions: + +```python +# Recommended +from pact import match + +match.int(...) + +# Not recommended +from pact.match import int + +int(...) +``` +/// + + +Many functions in this module are named after the types they match (e.g., `int`, +`str`, `bool`). Importing directly from this module may shadow Python built-in +types, so always use the `match` module. + +Matching rules are often combined with generators, which allow Pact to produce +values dynamically during contract tests. If a `value` is not provided, a +generator is used; if a `value` is provided, a generator is not used. This is +_not_ advised, as leads to non-deterministic tests. + +/// note +You do not need to specify everything that will be returned from the provider in +a JSON response. Any extra data that is received will be ignored and the tests +will still pass, as long as the expected fields match the defined patterns. +/// + + +For more information about the Pact matching specification, see +[Matching](https://docs.pact.io/getting_started/matching). + +## Type Matching + +The most common matchers validate that values are of a specific type. These +matchers can optionally accept example values: + +```python +from pact import match + +response = { + "id": match.int(123), # Any integer (example: 123) + "name": match.str("Alice"), # Any string (example: "Alice") + "score": match.float(98.5), # Any float (example: 98.5) + "active": match.bool(True), # Any boolean (example: True) + "tags": match.each_like("admin"), # Array of strings (example: ["admin"]) +} +``` + +When no example value is provided, Pact will generate appropriate values +automatically, but this is _not_ advised, as it leads to non-deterministic +tests. + +## Regular Expression Matching + +For values that must match a specific pattern, use `match.regex()` with a +regular expression: + +```python +response = { + "reference": match.regex("X1234-456def", regex=r"[A-Z]\d{3,6}-[0-9a-f]{6}"), + "phone": match.regex("+1-555-123-4567", regex=r"\+1-\d{3}-\d{3}-\d{4}"), +} +``` + +Note that the regular expression should be provided as a raw string (using the +`r"..."` syntax) to avoid issues with escape sequences. Advanced regex features +like lookaheads and lookbehinds should be avoided, as they may not be supported +by all Pact implementations. + +## Complex Objects + +For complex nested objects, matchers can be combined to create sophisticated +matching rules: + +```python +from pact import match + +user_response = { + "id": match.int(123), + "name": match.str("Alice"), + "email": match.regex( + "alice@example.com", + regex=r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", + ), + "confirmed": match.bool(True), + "address": { + "street": match.str("123 Main St"), + "city": match.str("Anytown"), + "postal_code": match.regex("12345", regex=r"\d{5}"), + }, + "roles": match.each_like(match.str("admin")), # Array of strings +} +``` + +The `match.type()` (or its alias `match.like()`) function provides generic type +matching for any value: + +```python +# These are equivalent to the specific type matchers +response = { + "id": match.type(123), # Same as match.int(123) + "name": match.like("Alice"), # Same as match.str("Alice") +} +``` + +## Array Matching + +For arrays where each element should match a specific pattern, use +`match.each_like()`: + +```python +from pact import match + +# Simple arrays +response = { + "tags": match.each_like(match.str("admin")), # Array of strings + "scores": match.each_like(match.int(95)), # Array of integers + "active": match.each_like(match.bool(True)), # Array of booleans +} + +# Complex nested objects in arrays +users_response = { + "users": match.each_like({ + "id": match.int(123), + "username": match.regex("alice123", regex=r"[a-zA-Z]+\d*"), + "roles": match.each_like(match.str("user")), # Nested array + }) +} +``` + +You can also control the minimum and maximum number of array elements: + +```python +response = {"items": match.each_like(match.str("item"), min=1, max=10)} +``` + +For arrays that must contain specific elements regardless of order, use +`match.array_containing()`. For example, to ensure an array includes certain +permissions: + +```python +response = { + "permissions": match.array_containing([ + match.str("read"), + match.str("write"), + match.regex("admin-edit", regex=r"admin-\w+"), + ]) +} +``` + +Note that additional elements may be present in the array; the matcher only +ensures the specified elements are included. + +## Date and Time Matching + +The `match` module provides specialized matchers for date and time values: + +```python +from pact import match +from datetime import date, time, datetime + +response = { + # Date matching (YYYY-MM-DD format by default) + "birth_date": match.date("2024-07-20"), + "birth_date_obj": match.date(date(2024, 7, 20)), + # Time matching (HH:MM:SS format by default) + "start_time": match.time("14:30:00"), + "start_time_obj": match.time(time(14, 30, 0)), + # DateTime matching (ISO 8601 format by default) + "created_at": match.datetime("2024-07-20T14:30:00+00:00"), + "updated_at": match.datetime(datetime(2024, 7, 20, 14, 30, 0)), + # Custom formats using Python strftime patterns + "custom_date": match.date("07/20/2024", format="%m/%d/%Y"), + "custom_time": match.time("2:30 PM", format="%I:%M %p"), +} +``` + +## Specialized Matchers + +Other commonly used matchers include: + +```python +from pact import match + +response = { + # UUID matching with different formats + "id": match.uuid("550e8400-e29b-41d4-a716-446655440000"), + "simple_id": match.uuid(format="simple"), # No hyphens + "uppercase_id": match.uuid(format="uppercase"), # Uppercase letters + # Number matching with constraints + "age": match.int(25, min=18, max=99), + "price": match.float(19.99, precision=2), + "count": match.number(42), # Generic number matcher + # String matching with constraints + "username": match.str("alice123", size=8), + "description": match.str(), # Any string + # Null values + "optional_field": match.none(), # or match.null() + # String inclusion matching + "message": match.includes("success"), # Must contain "success" +} +``` + +## Advanced Dictionary Matching + +For dynamic dictionary structures, you can match keys and values separately: + +```python +# Match each key against a pattern +user_permissions = match.each_key_matches( + {"admin-read": True, "admin-write": False}, + rules=match.regex("admin-read", regex=r"admin-\w+"), +) + +# Match each value against a pattern +user_scores = match.each_value_matches( + {"math": 95, "science": 87}, rules=match.int(85, min=0, max=100) +) +``` + +""" + +from __future__ import annotations + +import builtins +import datetime as dt +import warnings +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload + +from pact import generate +from pact._util import strftime_to_simple_date_format +from pact.match.matcher import ( + AbstractMatcher, + ArrayContainsMatcher, + EachKeyMatcher, + EachValueMatcher, + GenericMatcher, +) +from pact.types import UNSET, Matchable, Unset + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from types import ModuleType + + from pact.generate import AbstractGenerator + +# ruff: noqa: A001 +# We provide a more 'Pythonic' interface by matching the names of the +# functions to the types they match (e.g., `match.int` matches integers). +# This overrides the built-in types which are accessed via the `builtins` +# module. +# ruff: noqa: A002 +# We only for overrides of built-ins like `min`, `max` and `type` as +# arguments to provide a nicer interface for the user. + +# The Pact specification allows for arbitrary matching rules to be defined; +# however in practice, only the matchers provided by the FFI are used and +# supported. +# +# +__all__ = [ + "AbstractMatcher", + "array_containing", + "bool", + "boolean", + "date", + "datetime", + "decimal", + "each_key_matches", + "each_like", + "each_value_matches", + "float", + "includes", + "int", + "integer", + "like", + "none", + "null", + "number", + "regex", + "str", + "string", + "time", + "timestamp", + "type", + "uuid", +] + +_T = TypeVar("_T") + + +# We prevent users from importing from this module to avoid shadowing built-ins. +__builtins_import = builtins.__import__ + + +def __import__( # noqa: N807 + name: builtins.str, + globals: Mapping[builtins.str, object] | None = None, + locals: Mapping[builtins.str, object] | None = None, + fromlist: Sequence[builtins.str] | None = None, + level: builtins.int = 0, +) -> ModuleType: + """ + Override to warn when importing functions directly from this module. + + This function overrides the built-in `__import__` to warn + users when importing functions directly from this module, helping to + avoid shadowing built-in types and functions. + """ + __tracebackhide__ = True + fromlist = fromlist or () + if name == "pact.match" and len(set(fromlist) - {"AbstractMatcher"}) > 0: + warnings.warn( + "Avoid `from pact.match import `. " + "Prefer importing `match` and use `match.`", + stacklevel=2, + ) + return __builtins_import(name, globals, locals, fromlist, level) + + +builtins.__import__ = __import__ + + +def int( + value: builtins.int | Unset = UNSET, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> AbstractMatcher[builtins.int]: + """ + Match an integer value. + + Args: + value: + Example value for consumer test generation. + + min: + Minimum value to generate, if set. + + max: + Maximum value to generate, if set. + + Returns: + Matcher for integer values. + """ + if value is UNSET: + return GenericMatcher( + "integer", + generator=generate.int(min=min, max=max), + ) + return GenericMatcher( + "integer", + value=value, + ) + + +def integer( + value: builtins.int | Unset = UNSET, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> AbstractMatcher[builtins.int]: + """ + Alias for [`match.int`][int]. + """ + return int(value, min=min, max=max) + + +_NumberT = TypeVar("_NumberT", builtins.int, builtins.float, Decimal) + + +def float( + value: _NumberT | Unset = UNSET, + /, + *, + precision: builtins.int | None = None, +) -> AbstractMatcher[_NumberT]: + """ + Match a floating-point number. + + Args: + value: + Example value for consumer test generation. + + precision: + Number of decimal places to generate. + + Returns: + Matcher for floating-point numbers. + """ + if value is UNSET: + return GenericMatcher( + "decimal", + generator=generate.float(precision), + ) + return GenericMatcher( + "decimal", + value, + ) + + +def decimal( + value: _NumberT | Unset = UNSET, + /, + *, + precision: builtins.int | None = None, +) -> AbstractMatcher[_NumberT]: + """ + Alias for [`match.float`][float]. + """ + return float(value, precision=precision) + + +@overload +def number( + value: builtins.int, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> AbstractMatcher[builtins.int]: ... +@overload +def number( + value: builtins.float, + /, + *, + precision: builtins.int | None = None, +) -> AbstractMatcher[builtins.float]: ... +@overload +def number( + value: Decimal, + /, + *, + precision: builtins.int | None = None, +) -> AbstractMatcher[Decimal]: ... +@overload +def number( + value: Unset = UNSET, + /, +) -> AbstractMatcher[builtins.float]: ... +def number( + value: builtins.int | builtins.float | Decimal | Unset = UNSET, # noqa: PYI041 + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, + precision: builtins.int | None = None, +) -> ( + AbstractMatcher[builtins.int] + | AbstractMatcher[builtins.float] + | AbstractMatcher[Decimal] +): + """ + Match any number (integer, float, or Decimal). + + Args: + value: + Example value for consumer test generation. + + min: + Minimum value to generate (for integers). + + max: + Maximum value to generate (for integers). + + precision: + Number of decimal digits to generate (for floats). + + Returns: + Matcher for numbers (integer, float, or Decimal). + """ + if value is UNSET: + if min is not None or max is not None: + generator = generate.int(min=min, max=max) + elif precision is not None: + generator = generate.float(precision) + else: + msg = "At least one of min, max, or precision must be provided." + raise ValueError(msg) + return GenericMatcher("number", generator=generator) + + if isinstance(value, builtins.int): + if precision is not None: + warnings.warn( + "The precision argument is ignored when value is an integer.", + stacklevel=2, + ) + return GenericMatcher( + "number", + value=value, + ) + + if isinstance(value, builtins.float): + if min is not None or max is not None: + warnings.warn( + "The min and max arguments are ignored when value is not an integer.", + stacklevel=2, + ) + return GenericMatcher( + "number", + value=value, + ) + + if isinstance(value, Decimal): + if min is not None or max is not None: + warnings.warn( + "The min and max arguments are ignored when value is not an integer.", + stacklevel=2, + ) + return GenericMatcher( + "number", + value=value, + ) + + msg = f"Unsupported number type: {builtins.type(value)}" + raise TypeError(msg) + + +def str( + value: builtins.str | Unset = UNSET, + /, + *, + size: builtins.int | None = None, + generator: AbstractGenerator | None = None, +) -> AbstractMatcher[builtins.str]: + """ + Match a string value, optionally with a specific length. + + Args: + value: + Example value for consumer test generation. + + size: + Length of string to generate for consumer test. + + generator: + Alternative generator for consumer test. If set, ignores `size`. + + Returns: + Matcher for string values. + """ + if value is UNSET: + if size and generator: + warnings.warn( + "The size argument is ignored when a generator is provided.", + stacklevel=2, + ) + return GenericMatcher( + "type", + value="string", + generator=generator or generate.str(size), + ) + + if size is not None or generator: + warnings.warn( + "The size and generator arguments are ignored when a value is provided.", + stacklevel=2, + ) + return GenericMatcher( + "type", + value=value, + ) + + +def string( + value: builtins.str | Unset = UNSET, + /, + *, + size: builtins.int | None = None, + generator: AbstractGenerator | None = None, +) -> AbstractMatcher[builtins.str]: + """ + Alias for [`match.str`][str]. + """ + return str(value, size=size, generator=generator) + + +def regex( + value: builtins.str | Unset = UNSET, + /, + *, + regex: builtins.str | None = None, +) -> AbstractMatcher[builtins.str]: + """ + Match a string against a regular expression. + + Args: + value: + Example value for consumer test generation. + + regex: + Regular expression pattern to match. + + Returns: + Matcher for strings matching the given regular expression. + """ + if regex is None: + msg = "A regex pattern must be provided." + raise ValueError(msg) + + if value is UNSET: + return GenericMatcher( + "regex", + generator=generate.regex(regex), + regex=regex, + ) + return GenericMatcher( + "regex", + value, + regex=regex, + ) + + +_UUID_FORMAT_NAMES = Literal["simple", "lowercase", "uppercase", "urn"] +_UUID_FORMATS: dict[_UUID_FORMAT_NAMES, builtins.str] = { + "simple": r"[0-9a-fA-F]{32}", + "lowercase": r"[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}", + "uppercase": r"[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}", + "urn": r"urn:uuid:[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}", +} + + +def uuid( + value: builtins.str | Unset = UNSET, + /, + *, + format: _UUID_FORMAT_NAMES | None = None, +) -> AbstractMatcher[builtins.str]: + """ + Match a UUID value. + + See [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122) for details + about the UUID format. Some common, albeit non-compliant, alternative + formats are also supported. + + Args: + value: + Example value for consumer test generation. + + format: + Specify UUID format: + + - `simple`: 32 hexadecimal digits, no hyphens (not standard, for + convenience). + - `lowercase`: Lowercase hexadecimal digits with hyphens. + - `uppercase`: Uppercase hexadecimal digits with hyphens. + - `urn`: Lowercase hexadecimal digits with hyphens and `urn:uuid:` prefix. + + If not set, matches any case. + + Returns: + Matcher for UUID strings. + """ + pattern = ( + rf"^{_UUID_FORMATS[format]}$" + if format + else rf"^({_UUID_FORMATS['lowercase']}|{_UUID_FORMATS['uppercase']})$" + ) + if value is UNSET: + return GenericMatcher( + "regex", + generator=generate.uuid(format or "lowercase"), + regex=pattern, + ) + return GenericMatcher( + "regex", + value=value, + regex=pattern, + ) + + +def bool(value: builtins.bool | Unset = UNSET, /) -> AbstractMatcher[builtins.bool]: + """ + Match a boolean value. + + Args: + value: + Example value for consumer test generation. + + Returns: + Matcher for boolean values. + """ + if value is UNSET: + return GenericMatcher("boolean", generator=generate.bool()) + return GenericMatcher("boolean", value) + + +def boolean(value: builtins.bool | Unset = UNSET, /) -> AbstractMatcher[builtins.bool]: + """ + Alias for [`match.bool`][bool]. + """ + return bool(value) + + +def date( + value: dt.date | builtins.str | Unset = UNSET, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> AbstractMatcher[builtins.str]: + """ + Match a date value (string, no time component). + + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for Pact compatibility. + + Args: + value: + Example value for consumer test generation. + + format: + Date format string. Defaults to ISO 8601 (`%Y-%m-%d`). + + disable_conversion: + If True, the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format will be disabled, and the format must be + in Java's `SimpleDateFormat` format. As a result, the value must + be a string as Python cannot format the date in the target format. + + Returns: + Matcher for date strings. + """ + if disable_conversion: + if not isinstance(value, builtins.str): + msg = "When disable_conversion is True, the value must be a string." + raise ValueError(msg) + format = format or "yyyy-MM-dd" + if value is UNSET: + return GenericMatcher( + "date", + format=format, + generator=generate.date(format, disable_conversion=True), + ) + return GenericMatcher( + "date", + value=value, + format=format, + ) + + format = format or "%Y-%m-%d" + if isinstance(value, dt.date): + value = value.strftime(format) + format = strftime_to_simple_date_format(format) + + if value is UNSET: + return GenericMatcher( + "date", + format=format, + generator=generate.date(format, disable_conversion=True), + ) + return GenericMatcher( + "date", + value=value, + format=format, + ) + + +def time( + value: dt.time | builtins.str | Unset = UNSET, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> AbstractMatcher[builtins.str]: + """ + Match a time value (string, no date component). + + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for Pact compatibility. + + Args: + value: + Example value for consumer test generation. + + format: + Time format string. Defaults to ISO 8601 (`%H:%M:%S`). + + disable_conversion: + If True, disables conversion and expects Java format. Value must be + a string. + + Returns: + Matcher for time strings. + """ + if disable_conversion: + if not isinstance(value, builtins.str): + msg = "When disable_conversion is True, the value must be a string." + raise ValueError(msg) + format = format or "HH:mm:ss" + if value is UNSET: + return GenericMatcher( + "time", + format=format, + generator=generate.time(format, disable_conversion=True), + ) + return GenericMatcher( + "time", + value=value, + format=format, + ) + format = format or "%H:%M:%S" + if isinstance(value, dt.time): + value = value.strftime(format) + format = strftime_to_simple_date_format(format) + if value is UNSET: + return GenericMatcher( + "time", + format=format, + generator=generate.time(format, disable_conversion=True), + ) + return GenericMatcher( + "time", + value=value, + format=format, + ) + + +def datetime( + value: dt.datetime | builtins.str | Unset = UNSET, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> AbstractMatcher[builtins.str]: + """ + Match a datetime value (string, date and time). + + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for Pact compatibility. + + Args: + value: + Example value for consumer test generation. + + format: + Datetime format string. Defaults to ISO 8601 (`%Y-%m-%dT%H:%M:%S%z`). + + disable_conversion: + If True, disables conversion and expects Java format. Value must be + a string. + + Returns: + Matcher for datetime strings. + """ + if disable_conversion: + if not isinstance(value, builtins.str): + msg = "When disable_conversion is True, the value must be a string." + raise ValueError(msg) + format = format or "yyyy-MM-dd'T'HH:mm:ssZ" + if value is UNSET: + return GenericMatcher( + "timestamp", + format=format, + generator=generate.datetime(format, disable_conversion=True), + ) + return GenericMatcher( + "timestamp", + value=value, + format=format, + ) + format = format or "%Y-%m-%dT%H:%M:%S%z" + if isinstance(value, dt.datetime): + value = value.strftime(format) + format = strftime_to_simple_date_format(format) + if value is UNSET: + return GenericMatcher( + "timestamp", + format=format, + generator=generate.datetime(format, disable_conversion=True), + ) + return GenericMatcher( + "timestamp", + value=value, + format=format, + ) + + +def timestamp( + value: dt.datetime | builtins.str | Unset = UNSET, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> AbstractMatcher[builtins.str]: + """ + Alias for [`match.datetime`][datetime]. + """ + return datetime(value, format, disable_conversion=disable_conversion) + + +def none() -> AbstractMatcher[None]: + """ + Match a null value. + """ + return GenericMatcher("null") + + +def null() -> AbstractMatcher[None]: + """ + Alias for [`match.none`][none]. + """ + return none() + + +def type( + value: _T, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, + generator: AbstractGenerator | None = None, +) -> AbstractMatcher[_T]: + """ + Match a value by type (primitive or complex). + + Args: + value: + Value to match (primitive or complex). + + min: + Minimum number of items to match. + + max: + Maximum number of items to match. + + generator: + Generator to use for value generation. + + Returns: + Matcher for the given value type. + """ + if value is UNSET: + if not generator: + msg = "A generator must be provided when value is not set." + raise ValueError(msg) + return GenericMatcher("type", min=min, max=max, generator=generator) + return GenericMatcher("type", value, min=min, max=max, generator=generator) + + +def content_type(content_type: builtins.str) -> AbstractMatcher[Any]: + """ + Match a value by content type. + + Unlike other matchers, this matcher does not take a `value` argument, as it + is typically used to match binary data (e.g., images, files) where the + actual content is impractical to specify. + + Args: + content_type: + Content type to match (e.g., "image/jpeg", "application/pdf"). + + Returns: + Matcher for the given content type. + """ + return GenericMatcher("contentType", value=content_type) + + +def like( + value: _T, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, + generator: AbstractGenerator | None = None, +) -> AbstractMatcher[_T]: + """ + Alias for [`match.type`][type]. + """ + return type(value, min=min, max=max, generator=generator) + + +def each_like( + value: _T, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> AbstractMatcher[Sequence[_T]]: # type: ignore[type-var] + """ + Match each item in an array against a value (can be a matcher). + + Args: + value: + Value to match against (can be a matcher). + + min: + Minimum number of items to match (minimum is always 1). + + max: + Maximum number of items to match. + + Returns: + Matcher for arrays where each item matches the value. + """ + if min is not None and min < 1: + warnings.warn( + "The minimum number of items must be at least 1.", + stacklevel=2, + ) + return GenericMatcher("type", value=[value], min=min, max=max) # type: ignore[return-value] + + +def includes( + value: builtins.str, + /, + *, + generator: AbstractGenerator | None = None, +) -> AbstractMatcher[builtins.str]: + """ + Match a string that includes a given value. + + Args: + value: + Value to match against. + + generator: + Generator to use for value generation. + + Returns: + Matcher for strings that include the given value. + """ + return GenericMatcher( + "include", + value=value, + generator=generator, + ) + + +def array_containing( + variants: Sequence[_T | AbstractMatcher[_T]], / +) -> AbstractMatcher[Sequence[_T]]: + """ + Match an array containing the given variants. + + Each variant must occur at least once. Variants may be matchers or objects. + + Args: + variants: + List of variants to match against. + + Returns: + Matcher for arrays containing the given variants. + """ + return ArrayContainsMatcher(variants=variants) + + +def each_key_matches( + value: Mapping[_T, Any], + /, + *, + rules: AbstractMatcher[_T] | list[AbstractMatcher[_T]], +) -> AbstractMatcher[Mapping[_T, Matchable]]: + """ + Match each key in a dictionary against rules. + + Args: + value: + Dictionary to match against. + + rules: + Matching rules for each key. + + Returns: + Matcher for dictionaries where each key matches the rules. + """ + if isinstance(rules, AbstractMatcher): + rules = [rules] + return EachKeyMatcher(value=value, rules=rules) + + +def each_value_matches( + value: Mapping[Any, _T], + /, + *, + rules: AbstractMatcher[_T] | list[AbstractMatcher[_T]], +) -> AbstractMatcher[Mapping[Matchable, _T]]: + """ + Match each value in a dictionary against rules. + + Args: + value: + Dictionary to match against. + + rules: + Matching rules for each value. + + Returns: + Matcher for dictionaries where each value matches the rules. + """ + if isinstance(rules, AbstractMatcher): + rules = [rules] + return EachValueMatcher(value=value, rules=rules) diff --git a/src/pact/match/matcher.py b/src/pact/match/matcher.py new file mode 100644 index 000000000..74c747eff --- /dev/null +++ b/src/pact/match/matcher.py @@ -0,0 +1,458 @@ +""" +Matching functionality for Pact. + +Matchers are used in Pact to allow for more flexible matching of data. While the +consumer defines the expected request and response, there are circumstances +where the provider may return dynamically generated data. In these cases, the +consumer should use a matcher to define the expected data. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence +from itertools import chain +from json import JSONEncoder +from typing import Any, Generic, TypeVar + +from pact.generate.generator import AbstractGenerator +from pact.types import UNSET, Matchable, MatcherType, Unset + +_T_co = TypeVar("_T_co", covariant=True) +_T = TypeVar("_T") + + +class AbstractMatcher(ABC, Generic[_T_co]): + """ + Abstract matcher. + + In Pact, a matcher is used to define how a value should be compared. This + allows for more flexible matching of data, especially when the provider + returns dynamically generated data. + + This class is abstract and should not be used directly. Instead, use one of + the concrete matcher classes. Alternatively, you can create your own matcher + by subclassing this class. + + The matcher provides methods to convert into an integration JSON object and + a matching rule. These methods are used internally by the Pact library when + communicating with the FFI and generating the Pact file. + """ + + @abstractmethod + def to_integration_json(self) -> dict[str, Any]: + """ + Convert the matcher to an integration JSON object. + + This method is used internally to convert the matcher to a JSON object + which can be embedded directly in a number of places in the Pact FFI. + + For more information about this format, see the [integration JSON + docs](https://docs.pact.io/implementation_guides/rust/pact_ffi/integrationjson). + + Returns: + The matcher as an integration JSON object. + """ + + @abstractmethod + def to_matching_rule(self) -> dict[str, Any]: + """ + Convert the matcher to a matching rule. + + This method is used internally to convert the matcher to a matching rule + which can be embedded directly in a Pact file. + + For more information about this format, refer to the [Pact + specification](https://github.com/pact-foundation/pact-specification/tree/version-4) + and the [matchers + section](https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers) + + Returns: + The matcher as a matching rule. + """ + + def has_value(self) -> bool: + """ + Check if the matcher has a value. + + If a value is present, it _must_ be accessible via the `value` + attribute. + + Returns: + True if the matcher has a value, otherwise False. + """ + return not isinstance(getattr(self, "value", UNSET), Unset) + + def __and__(self, other: object) -> AndMatcher[Any]: + """ + Combine two matchers using a logical AND. + + This allows for combining multiple matchers into a single matcher that + requires all conditions to be met. + + Only a single example value is supported when combining matchers. The + first value found will be used. + + Args: + other: + The other matcher to combine with. + + Returns: + An `AndMatcher` that combines both matchers. + """ + if isinstance(self, AndMatcher) and isinstance(other, AbstractMatcher): + return AndMatcher(*self._matchers, other) # type: ignore[attr-defined] + if isinstance(other, AndMatcher): + return AndMatcher(self, *other._matchers) # type: ignore[attr-defined] + if isinstance(other, AbstractMatcher): + return AndMatcher(self, other) + return NotImplemented + + +class GenericMatcher(AbstractMatcher[_T_co]): + """ + Generic matcher. + + A generic matcher, with the ability to define arbitrary additional fields + for inclusion in the integration JSON object and matching rule. + """ + + def __init__( + self, + type: MatcherType, # noqa: A002 + /, + value: _T_co | Unset = UNSET, + generator: AbstractGenerator | None = None, + extra_fields: Mapping[str, Any] | None = None, + **kwargs: Matchable, + ) -> None: + """ + Initialize the matcher. + + Args: + type: + The type of the matcher. + + value: + The value to match. If not provided, the Pact library will + generate a value based on the matcher type (or use the generator + if provided). To ensure reproducibility, it is _highly_ + recommended to provide a value when creating a matcher. + + generator: + The generator to use when generating the value. The generator + will generally only be used if value is not provided. + + extra_fields: + Additional configuration elements to pass to the matcher. These + fields will be used when converting the matcher to both an + integration JSON object and a matching rule. + + **kwargs: + Alternative way to define extra fields. See the `extra_fields` + argument for more information. + """ + self.type = type + """ + The type of the matcher. + """ + + self.value: _T_co | Unset = value + """ + Default value used by Pact when executing tests. + """ + + self.generator = generator + """ + Generator used to generate a value when the value is not provided. + """ + + self._extra_fields: Mapping[str, Any] = dict( + chain((extra_fields or {}).items(), kwargs.items()) + ) + + def to_integration_json(self) -> dict[str, Any]: + """ + Convert the matcher to an integration JSON object. + + See + [`AbstractMatcher.to_integration_json`][AbstractMatcher.to_integration_json] + for more information. + """ + return { + "pact:matcher:type": self.type, + **({"value": self.value} if not isinstance(self.value, Unset) else {}), + **( + self.generator.to_integration_json() + if self.generator is not None + else {} + ), + **self._extra_fields, + } + + def to_matching_rule(self) -> dict[str, Any]: + """ + Convert the matcher to a matching rule. + + See + [`AbstractMatcher.to_matching_rule`][AbstractMatcher.to_matching_rule] + for more information. + """ + return { + "match": self.type, + **({"value": self.value} if not isinstance(self.value, Unset) else {}), + **self._extra_fields, + } + + +class ArrayContainsMatcher(AbstractMatcher[Sequence[_T_co]]): + """ + Array contains matcher. + + A matcher that checks if an array contains a value. + """ + + def __init__(self, variants: Sequence[_T_co | AbstractMatcher[_T_co]]) -> None: + """ + Initialize the matcher. + + Args: + variants: + List of possible values to match against. + """ + self._matcher: AbstractMatcher[Sequence[_T_co]] = GenericMatcher( + "arrayContains", + extra_fields={"variants": variants}, + ) + + def to_integration_json(self) -> dict[str, Any]: + """ + Convert the matcher to an integration JSON object. + + See + [`AbstractMatcher.to_integration_json`][AbstractMatcher.to_integration_json] + for more information. + """ + return self._matcher.to_integration_json() + + def to_matching_rule(self) -> dict[str, Any]: + """ + Convert the matcher to a matching rule. + + See + [`AbstractMatcher.to_matching_rule`][AbstractMatcher.to_matching_rule] + for more information. + """ + return self._matcher.to_matching_rule() + + +class EachKeyMatcher(AbstractMatcher[Mapping[_T, Matchable]]): + """ + Each key matcher. + + A matcher that applies a matcher to each key in a mapping. + """ + + def __init__( + self, + value: Mapping[_T, Matchable], + rules: list[AbstractMatcher[_T]] | None = None, + ) -> None: + """ + Initialize the matcher. + + Args: + value: + Example value to match against. + + rules: + List of matchers to apply to each key in the mapping. + """ + self._matcher: AbstractMatcher[Mapping[_T, Matchable]] = GenericMatcher( + "eachKey", + value=value, + extra_fields={"rules": rules}, + ) + + def to_integration_json(self) -> dict[str, Any]: + """ + Convert the matcher to an integration JSON object. + + See + [`AbstractMatcher.to_integration_json`][AbstractMatcher.to_integration_json] + for more information. + """ + return self._matcher.to_integration_json() + + def to_matching_rule(self) -> dict[str, Any]: + """ + Convert the matcher to a matching rule. + + See + [`AbstractMatcher.to_matching_rule`][AbstractMatcher.to_matching_rule] + for more information. + """ + return self._matcher.to_matching_rule() + + +class EachValueMatcher(AbstractMatcher[Mapping[Matchable, _T_co]]): + """ + Each value matcher. + + A matcher that applies a matcher to each value in a mapping. + """ + + def __init__( + self, + value: Mapping[Matchable, _T_co], + rules: list[AbstractMatcher[_T_co]] | None = None, + ) -> None: + """ + Initialize the matcher. + + Args: + value: + Example value to match against. + + rules: + List of matchers to apply to each value in the mapping. + """ + self._matcher: AbstractMatcher[Mapping[Matchable, _T_co]] = GenericMatcher( + "eachValue", + value=value, + extra_fields={"rules": rules}, + ) + + def to_integration_json(self) -> dict[str, Any]: + """ + Convert the matcher to an integration JSON object. + + See + [`AbstractMatcher.to_integration_json`][AbstractMatcher.to_integration_json] + for more information. + """ + return self._matcher.to_integration_json() + + def to_matching_rule(self) -> dict[str, Any]: + """ + Convert the matcher to a matching rule. + + See + [`AbstractMatcher.to_matching_rule`][AbstractMatcher.to_matching_rule] + for more information. + """ + return self._matcher.to_matching_rule() + + +class AndMatcher(AbstractMatcher[_T_co]): + """ + And matcher. + + A matcher that combines multiple matchers using a logical AND. + """ + + def __init__( + self, + *matchers: AbstractMatcher[Any], + value: _T_co | Unset = UNSET, + ) -> None: + """ + Initialize the matcher. + + It is best practice to provide a value. This may be set when creating + the `AndMatcher`, or it may be inferred from one of the constituent + matchers. In the latter case, the value from the first matcher that has + a value will be used. + + Args: + matchers: + List of matchers to combine. + + value: + Example value to match against. If not provided, the value + from the first matcher that has a value will be used. + """ + self._matchers = matchers + self._value: _T_co | Unset = value + + if isinstance(self._value, Unset): + for matcher in matchers: + if matcher.has_value(): + # If `has_value` is true, `value` must be present + self._value = matcher.value # type: ignore[attr-defined] + break + + def to_integration_json(self) -> dict[str, Any]: + """ + Convert the matcher to an integration JSON object. + + See + [`AbstractMatcher.to_integration_json`][AbstractMatcher.to_integration_json] + for more information. + """ + return {"pact:matcher:type": [m.to_integration_json() for m in self._matchers]} + + def to_matching_rule(self) -> dict[str, Any]: + """ + Convert the matcher to a matching rule. + + See + [`AbstractMatcher.to_matching_rule`][AbstractMatcher.to_matching_rule] + for more information. + """ + return { + "combine": "AND", + "matchers": [m.to_matching_rule() for m in self._matchers], + } + + +class MatchingRuleJSONEncoder(JSONEncoder): + """ + JSON encoder class for matching rules. + + This class is used to encode matching rules to JSON. + """ + + def default(self, o: Any) -> Any: # noqa: ANN401 + """ + Encode the object to JSON. + + Args: + o: + The object to encode. + + Returns: + The encoded object. + """ + if isinstance(o, AndMatcher): + return o.to_matching_rule() + if isinstance(o, AbstractMatcher): + # We need to convert all matchers in AndMatchers (even if there is + # only one). + return AndMatcher(o).to_matching_rule() + return super().default(o) + + +class IntegrationJSONEncoder(JSONEncoder): + """ + JSON encoder class for integration JSON objects. + + This class is used to encode integration JSON objects to JSON. + """ + + def default(self, o: Any) -> Any: # noqa: ANN401 + """ + Encode the object to JSON. + + Args: + o: + The object to encode. + + Returns: + The encoded object. + """ + if isinstance(o, AbstractMatcher): + return o.to_integration_json() + if isinstance(o, AbstractGenerator): + return o.to_integration_json() + return super().default(o) diff --git a/src/pact/pact.py b/src/pact/pact.py new file mode 100644 index 000000000..18c975ec2 --- /dev/null +++ b/src/pact/pact.py @@ -0,0 +1,887 @@ +""" +Pact between a consumer and a provider. + +This module defines the classes that are used to define a Pact between a +consumer and a provider. It defines the interactions between the two parties, +and provides the functionality to verify that the interactions are satisfied. + +As Pact is a consumer-driven contract testing tool, the consumer is responsible +for defining the interactions between the two parties. The provider is then +responsible for ensuring that these interactions are satisfied. + +## Usage + +The main class in this module is the [`Pact`][pact.Pact] class. This class +defines the Pact between the consumer and the provider. It is responsible for +defining the interactions between the two parties. + +The general usage of this module is as follows: + +```python +from pact import Pact +import aiohttp + + +pact = Pact("consumer", "provider") + +interaction = pact.upon_receiving("a basic request") +interaction.given("user 123 exists") +interaction.with_request("GET", "/user/123") +interaction.will_respond_with(200) +interaction.with_header("Content-Type", "application/json") +interaction.with_body({"id": 123, "name": "Alice"}) + +with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.get("/user/123") as resp: + assert resp.status == 200 + assert resp.headers["Content-Type"] == "application/json" + data = await resp.json() + assert data == {"id": 123, "name": "Alice"} +``` + +The repeated calls to `interaction` can be chained together to define the +interaction in a more concise manner: + +```python +pact = Pact("consumer", "provider") + +( + pact + .upon_receiving("a basic request") + .given("user 123 exists") + .with_request("GET", "/user/123") + .will_respond_with(200) + .with_header("Content-Type", "application/json") + .with_body({"id": 123, "name": "Alice"}) +) +``` + +Note that the parentheses are required to ensure that the method chaining works +correctly, as this form of method chaining is not typical in Python. +""" + +from __future__ import annotations + +import json +import logging +import warnings +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Literal, + overload, +) + +from yarl import URL + +import pact_ffi +from pact.error import ( + InteractionVerificationError, + Mismatch, + MismatchesError, + PactVerificationError, +) +from pact.interaction._async_message_interaction import AsyncMessageInteraction +from pact.interaction._http_interaction import HttpInteraction +from pact.interaction._sync_message_interaction import SyncMessageInteraction + +if TYPE_CHECKING: + from collections.abc import Callable, Generator + from types import TracebackType + + from typing_extensions import Self + + from pact.interaction import Interaction + +logger = logging.getLogger(__name__) + + +class Pact: + """ + A Pact between a consumer and a provider. + + This class defines a Pact between a consumer and a provider. It is the + central class in Pact's framework, and is responsible for defining the + interactions between the two parties. + + One `Pact` instance should be created for each provider that a consumer + interacts with. The methods on this class are used to define the broader + attributes of the Pact, such as the consumer and provider names, the Pact + specification, any plugins that are used, and any metadata that is attached + to the Pact. + + Each interaction between the consumer and the provider is defined through + the [`upon_receiving`][Pact.upon_receiving] method, which + returns a sub-class of [`Interaction`][interaction.Interaction]. + """ + + def __init__( + self, + consumer: str, + provider: str, + ) -> None: + """ + Initialise a new Pact. + + Args: + consumer: + Name of the consumer. + + provider: + Name of the provider. + + Raises: + ValueError: + If the consumer or provider name is empty. + """ + if not consumer: + msg = "Consumer name cannot be empty." + raise ValueError(msg) + if not provider: + msg = "Provider name cannot be empty." + raise ValueError(msg) + + self._consumer = consumer + self._provider = provider + self._interactions: set[Interaction] = set() + self._handle: pact_ffi.PactHandle = pact_ffi.new_pact( + consumer, + provider, + ) + + # Default to the latest version of the Pact specification, which + # can be changed later if required. + self.with_specification("V4") + + def __str__(self) -> str: + """ + Informal string representation of the Pact. + """ + return f"{self.consumer} -> {self.provider}" + + def __repr__(self) -> str: + """ + Information-rich string representation of the Pact. + """ + return "".format( + ", ".join( + [ + f"consumer={self.consumer!r}", + f"provider={self.provider!r}", + f"handle={self._handle!r}", + ], + ), + ) + + @property + def consumer(self) -> str: + """ + Consumer name. + """ + return self._consumer + + @property + def provider(self) -> str: + """ + Provider name. + """ + return self._provider + + @property + def specification(self) -> pact_ffi.PactSpecification: + """ + Pact specification version. + """ + return pact_ffi.handle_get_pact_spec_version(self._handle) + + def with_specification( + self, + version: str | pact_ffi.PactSpecification, + ) -> Self: + """ + Set the Pact specification version. + + The Pact specification version indicates the features which are + supported by the Pact, and certain default behaviours. + + If this method is not called, then the Pact will use version 4 of the + specification by default. + + Args: + version: + Pact specification version. This can be either a string or a + [`PactSpecification`][pact_ffi.PactSpecification] instance. + + The version string is case insensitive and has an optional `v` + prefix. + """ + if isinstance(version, str): + version = pact_ffi.PactSpecification.from_str(version) + pact_ffi.with_specification(self._handle, version) + return self + + def using_plugin( + self, + name: str, + version: str | None = None, + delay: int | None = None, + ) -> Self: + """ + Add a plugin to be used by the test. + + Plugins extend the functionality of Pact. + + Args: + name: + Name of the plugin. + + version: + Version of the plugin. This is optional and can be `None`. + + delay: + An arbitrary delay in milliseconds to add before the function + returns to allow asynchronous tasks to complete. + """ + if delay is not None: + pact_ffi.using_plugin_with_delay(self._handle, name, version, delay) + else: + pact_ffi.using_plugin(self._handle, name, version) + return self + + def with_metadata( + self, + namespace: str, + metadata: dict[str, str], + ) -> Self: + """ + Set additional metadata for the Pact. + + A common use for this function is to add information about the client + library (name, version, hash, etc.) to the Pact. + + Args: + namespace: + Namespace for the metadata. This is used to group the metadata + together. + + metadata: + Key-value pairs of metadata to add to the Pact. + """ + for k, v in metadata.items(): + pact_ffi.with_pact_metadata(self._handle, namespace, k, v) + return self + + @overload + def upon_receiving( + self, + description: str, + interaction: Literal["HTTP"] = ..., + ) -> HttpInteraction: ... + + @overload + def upon_receiving( + self, + description: str, + interaction: Literal["Async"], + ) -> AsyncMessageInteraction: ... + + @overload + def upon_receiving( + self, + description: str, + interaction: Literal["Sync"], + ) -> SyncMessageInteraction: ... + + def upon_receiving( + self, + description: str, + interaction: Literal["HTTP", "Sync", "Async"] = "HTTP", + ) -> HttpInteraction | AsyncMessageInteraction | SyncMessageInteraction: + """ + Create a new Interaction. + + Args: + description: + Description of the interaction. This must be unique + within the Pact. + + interaction: + Type of interaction. Defaults to `HTTP`. This must be one of + `HTTP`, `Async`, or `Sync`. + + Raises: + ValueError: + If the interaction type is invalid. + """ + if interaction == "HTTP": + return HttpInteraction(self._handle, description) + if interaction == "Async": + return AsyncMessageInteraction(self._handle, description) + if interaction == "Sync": + return SyncMessageInteraction(self._handle, description) + + msg = f"Invalid interaction type: {interaction}" + raise ValueError(msg) + + def serve( # noqa: PLR0913 + self, + addr: str = "localhost", + port: int = 0, + transport: str = "http", + transport_config: str | None = None, + *, + raises: bool = True, + verbose: bool = True, + ) -> PactServer: + """ + Return a mock server for the Pact. + + This function configures a mock server for the Pact. The mock server + is then started when the Pact is entered into a `with` block: + + ```python + pact = Pact("consumer", "provider") + with pact.serve() as srv: + ... + ``` + + Args: + addr: + Address to bind the mock server to. Defaults to `localhost`. + + port: + Port to bind the mock server to. Defaults to `0`, which will + select a random port. + + transport: + Transport to use for the mock server. Defaults to `HTTP`. + + transport_config: + Configuration for the transport. This is specific to the + transport being used and should be a JSON string. + + raises: + Whether to raise an exception if there are mismatches between + the Pact and the server. If set to `False`, then the mismatches + must be handled manually. + + verbose: + Whether or not to print the mismatches to the logger. This works + independently of `raises`. + + Returns: + PactServer instance. + """ + return PactServer( + self._handle, + addr, + port, + transport, + transport_config, + raises=raises, + verbose=verbose, + ) + + @overload + def interactions( + self, + kind: Literal["HTTP"], + ) -> Generator[pact_ffi.SynchronousHttp, None, None]: ... + + @overload + def interactions( + self, + kind: Literal["Sync"], + ) -> Generator[pact_ffi.SynchronousMessage, None, None]: ... + + @overload + def interactions( + self, + kind: Literal["Async"], + ) -> Generator[pact_ffi.AsynchronousMessage, None, None]: ... + + @overload + def interactions( + self, + kind: Literal["All"], + ) -> Generator[ + pact_ffi.PactInteraction, + None, + None, + ]: ... + + def interactions( + self, + kind: Literal["HTTP", "Sync", "Async", "All"] = "HTTP", + ) -> ( + Generator[pact_ffi.SynchronousHttp, None, None] + | Generator[pact_ffi.SynchronousMessage, None, None] + | Generator[pact_ffi.AsynchronousMessage, None, None] + | Generator[ + pact_ffi.PactInteraction, + None, + None, + ] + ): + """ + Return an iterator over the Pact's interactions. + + The kind is used to specify the type of interactions that will be + iterated over. + + Raises: + ValueError: + If the kind is unknown. + """ + if kind == "All": + yield from pact_ffi.pact_model_interaction_iterator(self._handle.pointer()) + return + if kind == "HTTP": + yield from pact_ffi.pact_handle_get_sync_http_iter(self._handle) + return + if kind == "Sync": + yield from pact_ffi.pact_handle_get_sync_message_iter(self._handle) + return + if kind == "Async": + yield from pact_ffi.pact_handle_get_async_message_iter(self._handle) + return + + msg = f"Unknown interaction kind: {kind}" + raise ValueError(msg) + + @overload + def verify( + self, + handler: Callable[[str | bytes | None, dict[str, object]], None], + kind: Literal["Async", "Sync"], + *, + raises: Literal[True] = True, + ) -> None: ... + @overload + def verify( + self, + handler: Callable[[str | bytes | None, dict[str, object]], None], + kind: Literal["Async", "Sync"], + *, + raises: Literal[False], + ) -> list[InteractionVerificationError]: ... + + def verify( + self, + handler: Callable[[str | bytes | None, dict[str, object]], None], + kind: Literal["Async", "Sync"], + *, + raises: bool = True, + ) -> list[InteractionVerificationError] | None: + """ + Verify message interactions. + + This function is used to ensure that the consumer is able to handle the + messages that are defined in the Pact. The `handler` function is called + for each message in the Pact. + + The end-user is responsible for defining the `handler` function and + verifying that the messages are handled correctly. For example, if the + handler is meant to call an API, then the API call should be mocked out + and once the verification is complete, the mock should be verified. Any + exceptions raised by the handler will be caught and reported as + mismatches. + + Args: + handler: + The function that will be called for each message in the Pact. + + The first argument to the function is the message body, either as + a string or byte array. + + The second argument is the metadata for the message. If there + is no metadata, then this will be an empty dictionary. + + kind: + The type of message interaction. This must be one of `Async` + or `Sync`. + + raises: + Whether or not to raise an exception if the handler fails to + process a message. If set to `False`, then the function will + return a list of errors. + + Returns: + `None` if raises is True and no errors occurred, otherwise a list of + [`InteractionVerificationError`][error.InteractionVerificationError]. + + Raises: + TypeError: + If the message type is unknown. + + PactVerificationError: + If raises is True and there are errors. + """ + errors: list[InteractionVerificationError] = [] + for message in self.interactions(kind): + request: pact_ffi.MessageContents | None = None + if isinstance(message, pact_ffi.SynchronousMessage): + request = message.request_contents + elif isinstance(message, pact_ffi.AsynchronousMessage): + request = message.contents + else: + msg = f"Unknown message type: {type(message).__name__}" + raise TypeError(msg) + + if request is None: + warnings.warn( + f"Message '{message.description}' has no contents", + stacklevel=2, + ) + continue + + body = request.contents + metadata: dict[str, object] = {} + for pair in request.metadata: + try: + v = json.loads(pair.value) + except json.JSONDecodeError: + v = pair.value + metadata[pair.key] = v + + try: + handler(body, metadata) + except Exception as e: # noqa: BLE001 + errors.append(InteractionVerificationError(message.description, e)) + + if raises: + if errors: + raise PactVerificationError(errors) + return None + return errors + + def write_file( + self, + directory: Path | str | None = None, + *, + overwrite: bool = False, + ) -> None: + """ + Write out the pact to a file. + + This function should be called once all of the consumer tests have been + run. It writes the Pact to a file, which can then be used to validate + the provider. + + Args: + directory: + The directory to write the pact to. If the directory does not + exist, it will be created. The filename will be + automatically generated from the underlying Pact. + + overwrite: + If set to True, the file will be overwritten if it already + exists. Otherwise, the contents of the file will be merged with + the existing file. + """ + if directory is None: + directory = Path.cwd() + pact_ffi.pact_handle_write_file( + self._handle, + directory, + overwrite=overwrite, + ) + + +class PactServer: + """ + Pact Server. + + This class handles the lifecycle of the Pact mock server. It is responsible + for starting the mock server when the Pact is entered into a [`with` + block](https://docs.python.org/3/reference/compound_stmts.html#with), and + stopping the mock server when the block is exited. + + Note that the server should not be started directly, but rather through the + [`Pact.serve`][Pact.serve] method: + + ```python + pact = Pact("consumer", "provider") + # Define interactions... + with pact.serve() as srv: + ... + ``` + + The URL for the server can be accessed through its [`url`][PactServer.url] + attribute, which will be required in order to point the consumer client to + the mock server: + + ```python + pact = Pact("consumer", "provider") + with pact.serve() as srv: + api_client = MyApiClient(srv.url) + # Test the client... + ``` + + If the server is instantiated with `raises=True` (the default), then the + server will raise a `MismatchesError` if there are mismatches in any of the + interactions. If `raises=False`, then the mismatches must be handled + manually. + """ + + def __init__( # noqa: PLR0913 + self, + pact_handle: pact_ffi.PactHandle, + host: str = "localhost", + port: int | None = None, + transport: str = "HTTP", + transport_config: str | None = None, + *, + raises: bool = True, + verbose: bool = True, + ) -> None: + """ + Initialise a new Pact Server. + + Args: + pact_handle: + Handle for the Pact. + + host: + Hostname or IP for the mock server. + + port: + Port to bind the mock server to. The value of `None` will select + a random available port. + + transport: + Transport to use for the mock server. + + transport_config: + Configuration for the transport. This is specific to the + transport being used and should be a JSON string. + + raises: + Whether or not to raise an exception if the server is not + matched upon exit. + + verbose: + Whether or not to print the mismatches to the logger. This works + independently of `raises`. + """ + self._host = host + self._port = port or 0 + self._transport = transport + self._transport_config = transport_config + self._pact_handle = pact_handle + self._handle: None | pact_ffi.PactServerHandle = None + self._raises = raises + self._verbose = verbose + self._mismatches: list[Mismatch] | None = None + + @property + def port(self) -> int | None: + """ + Port on which the server is running. + + If the server is not running, then this will be `None`. + """ + # Unlike the other properties, this value might be different to what was + # passed in to the constructor as the server can be started on a random + # port. + return self._handle.port if self._handle else None + + @property + def host(self) -> str: + """ + Address to which the server is bound. + """ + return self._host + + @property + def transport(self) -> str: + """ + Transport method. + """ + return self._transport + + @property + def url(self) -> URL: + """ + Base URL for the server. + """ + return URL(str(self)) + + @property + def matched(self) -> bool: + """ + Whether or not the server has been matched. + + This is `True` if the server has been matched, and `False` otherwise. + + Raises: + RuntimeError: + If the server is not running. + """ + if not self._handle: + msg = "The server is not running." + raise RuntimeError(msg) + return pact_ffi.mock_server_matched(self._handle) + + @property + def mismatches(self) -> list[Mismatch]: + """ + Mismatches between the Pact and the server. + + This is a string containing the mismatches between the Pact and the + server. If there are no mismatches, then this is an empty string. + + Raises: + RuntimeError: + If the server is not running. + """ + if self._mismatches is not None: + return self._mismatches + + if not self._handle: + msg = "The server is not running." + raise RuntimeError(msg) + return list( + map( + Mismatch.from_dict, + pact_ffi.mock_server_mismatches(self._handle), + ) + ) + + @property + def logs(self) -> str | None: + """ + Logs from the server. + + This is a string containing the logs from the server. If there are no + logs, then this is `None`. For this to be populated, the logging must + be configured to make use of the internal buffer. + + Raises: + RuntimeError: + If the server is not running. + """ + if not self._handle: + msg = "The server is not running." + raise RuntimeError(msg) + + try: + return pact_ffi.mock_server_logs(self._handle) + except RuntimeError: + return None + + def __str__(self) -> str: + """ + URL for the server. + """ + return f"{self.transport}://{self.host}:{self.port}" + + def __repr__(self) -> str: + """ + Information-rich string representation of the Pact Server. + """ + return "".format( + ", ".join( + [ + f"transport={self.transport!r}", + f"host={self.host!r}", + f"port={self.port!r}", + f"handle={self._handle!r}", + f"pact={self._pact_handle!r}", + ], + ), + ) + + def __enter__(self) -> Self: + """ + Launch the server. + + Once the server is running, it is generally no possible to make + modifications to the underlying Pact. + """ + self._handle = pact_ffi.create_mock_server_for_transport( + self._pact_handle, + self._host, + self._port, + self._transport, + self._transport_config, + ) + + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """ + Stop the server. + + Raises: + MismatchesError: + If the server has not been fully matched and the server is + configured to raise an exception. + """ + if self._handle and not self.matched: + if self._verbose: + msg = "\n".join([ + "Mismatches:", + *(f" ({i + 1}) {m}" for i, m in enumerate(self.mismatches)), + ]) + logger.error(msg) + if self._raises: + raise MismatchesError(*self.mismatches) + self._mismatches = self.mismatches + self._handle = None + + def __truediv__(self, other: str | object) -> URL: + """ + URL for the server. + """ + if isinstance(other, str): + return self.url / other + return NotImplemented + + def write_file( + self, + directory: str | Path | None = None, + *, + overwrite: bool = False, + ) -> None: + """ + Write out the pact to a file. + + Args: + directory: + The directory to write the pact to. If the directory does not + exist, it will be created. The filename will be + automatically generated from the underlying Pact. + + overwrite: + Whether or not to overwrite the file if it already exists. + + Raises: + RuntimeError: + If the server is not running. + + ValueError: + If the path specified is not a directory. + """ + if not self._handle: + msg = "The server is not running." + raise RuntimeError(msg) + + directory = Path(directory) if directory else Path.cwd() + if not directory.exists(): + directory.mkdir(parents=True) + elif not directory.is_dir(): + msg = f"{directory} is not a directory" + raise ValueError(msg) + + pact_ffi.write_pact_file( + self._handle, + str(directory), + overwrite=overwrite, + ) diff --git a/src/pact/py.typed b/src/pact/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/pact/types.py b/src/pact/types.py new file mode 100644 index 000000000..bc2f021f4 --- /dev/null +++ b/src/pact/types.py @@ -0,0 +1,175 @@ +""" +Typing definitions for the matchers. + +This module provides basic type definitions, and is the runtime counterpart to +the `types.pyi` stub file. The latter is used to provide much richer type +information to static type checkers like `mypy`. +""" + +from __future__ import annotations + +from typing import Any, Literal, TypeAlias, TypedDict + +from yarl import URL + +Matchable: TypeAlias = Any +""" +All supported matchable types. +""" + +MatcherType: TypeAlias = str +""" +All supported matchers. +""" + +GeneratorType: TypeAlias = str +""" +All supported generator types. +""" + + +class Message(TypedDict): + """ + Message definition. + + This is a dictionary that is used to represent the message. This class can + be used as an initializer to create a new message, or the return of a + dictionary can be used directly. + """ + + contents: bytes + """ + Message contents. + + These are the actual contents of the message, as a `bytes` object. + """ + metadata: dict[str, Any] | None + """ + Any additional metadata associated with the message. + """ + content_type: str | None + """ + Content type of the message. + + This should be specified as a MIME type, such as `application/json`. + """ + + +class MessageProducerArgs(TypedDict, total=False): + """ + Arguments for the message handler functions. + + The message producer function must be able to accept these arguments. Pact + Python will inspect the function's type signature to determine how best to + pass the arguments in (e.g., as keyword arguments, position arguments, + variadic arguments, or a combination of these). Note that Pact Python will + prefer the use of keyword arguments if available, and therefore it is + recommended to allow keyword arguments for the fields below if possible. + """ + + name: str + """ + The name of the message. + + This is used to identify the message so that the function knows which + message to generate. This is typically a string that describes the + message. For example, `"a request to create a new user"` or `"a metric event + for a user login"`. + + This may be omitted if the message producer functions are passed through a + dictionary where the key is used to identify the message. + """ + + metadata: dict[str, Any] | None + """ + Metadata associated with the message. + """ + + +class StateHandlerArgs(TypedDict, total=False): + """ + Arguments for the state handler functions. + + The state handler function must be able to accept these arguments. Pact + Python will inspect the function's type signature to determine how best to + pass the arguments in (e.g., as keyword arguments, position arguments, + variadic arguments, or a combination of these). Note that Pact Python will + prefer the use of keyword arguments if available, and therefore it is + recommended to allow keyword arguments for the fields below if possible. + """ + + state: str + """ + The name of the state. + + This is used to identify the state so that the function knows which state to + generate. This is typically a string that describes the state. For example, + `"user exists"`. + + If the function is passed through a dictionary where the key is used to + identify the state, this argument is not required. + """ + + action: Literal["setup", "teardown"] + """ + The action to perform. + + This is either `"setup"` or `"teardown"`, and indicates whether the state + should be set up or torn down. + + This argument is only used if the state handler is expected to perform both + setup and teardown actions (i.e., if `teardown=True` is used when calling + [`Verifier.state_handler][pact.verifier.Verifier.state_handler]`). + """ + + parameters: dict[str, Any] | None + """ + Parameters required to generate the state. + + This can be used to pass in any additional parameters that are required to + generate the state. For example, if the state requires a user ID, this can + be passed in here. + """ + + +StateHandlerUrl: TypeAlias = str | URL +""" +State handler URL signature. + +Instead of providing a function to handle state changes, it is possible to +provide a URL endpoint to which the request should be made. +""" + + +class Unset: + """ + Special type to represent an unset value. + + Typically, the value `None` is used to represent an unset value. However, we + need to differentiate between a null value and an unset value. For example, + a matcher may have a value of `None`, which is different from a matcher + having no value at all. This class is used to represent the latter. + """ + + def __bool__(self) -> bool: + """ + Always return `False`. + + This allows the `Unset` instance to be used in boolean contexts. For + example: + + ```python + def f(v: str | Unset = UNSET): + v = v or "default" + ``` + """ + return False + + +UNSET = Unset() +""" +Instance of the `Unset` class. + +This is used to provide a default value for an optional argument that needs to +differentiate between a `None` value and an unset value. +""" diff --git a/src/pact/types.pyi b/src/pact/types.pyi new file mode 100644 index 000000000..7f4fc61aa --- /dev/null +++ b/src/pact/types.pyi @@ -0,0 +1,143 @@ +# Types stubs file +# +# This file is only used during type checking, and is ignored during runtime. +# As a result, it is safe to perform expensive imports, even if they are not +# used or available at runtime. + +from collections.abc import Collection, Mapping, Sequence +from collections.abc import Set as AbstractSet +from datetime import date, datetime, time +from decimal import Decimal +from fractions import Fraction +from typing import Any, Literal, TypeAlias, TypedDict + +from pydantic import BaseModel +from yarl import URL + +_BaseMatchable: TypeAlias = ( + int | float | complex | bool | str | bytes | bytearray | memoryview | None +) +""" +Base types that generally can't be further decomposed. + +See: https://docs.python.org/3/library/stdtypes.html +""" + +_ContainerMatchable: TypeAlias = ( + Sequence[Matchable] + | AbstractSet[Matchable] + | Mapping[Matchable, Matchable] + | Collection[Matchable] +) +""" +Containers that can be further decomposed. + +These are defined based on the abstract base classes defined in the +[`collections.abc`][collections.abc] module. +""" + +_StdlibMatchable: TypeAlias = Decimal | Fraction | date | time | datetime +""" +Standard library types. +""" + +_ExtraMatchable: TypeAlias = BaseModel +""" +Additional matchable types, typically from third-party libraries. +""" + +Matchable: TypeAlias = ( + _BaseMatchable | _ContainerMatchable | _StdlibMatchable | _ExtraMatchable +) +""" +All supported matchable types. +""" + +_MatcherTypeV3: TypeAlias = Literal[ + "equality", + "regex", + "type", + "include", + "integer", + "decimal", + "number", + "timestamp", + "time", + "date", + "null", + "boolean", + "contentType", + "values", + "arrayContains", +] +""" +Matchers defined in the V3 specification. +""" + +_MatcherTypeV4: TypeAlias = Literal[ + "statusCode", + "notEmpty", + "semver", + "eachKey", + "eachValue", +] +""" +Matchers defined in the V4 specification. +""" + +MatcherType: TypeAlias = _MatcherTypeV3 | _MatcherTypeV4 +""" +All supported matchers. +""" + +_GeneratorTypeV3: TypeAlias = Literal[ + "RandomInt", + "RandomDecimal", + "RandomHexadecimal", + "RandomString", + "Regex", + "Uuid", + "Date", + "Time", + "DateTime", + "RandomBoolean", +] +""" +Generators defines in the V3 specification. +""" + +_GeneratorTypeV4: TypeAlias = Literal["ProviderState", "MockServerURL"] +""" +Generators defined in the V4 specification. +""" + +GeneratorType: TypeAlias = _GeneratorTypeV3 | _GeneratorTypeV4 +""" +All supported generator types. +""" + +class Message(TypedDict): + contents: bytes + metadata: dict[str, Any] | None + content_type: str | None + +class MessageProducerArgs(TypedDict, total=False): + name: str + metadata: dict[str, Any] | None + +class StateHandlerArgs(TypedDict, total=False): + state: str + action: Literal["setup", "teardown"] + parameters: dict[str, Any] | None + +StateHandlerUrl: TypeAlias = str | URL +""" +State handler URL signature. + +Instead of providing a function to handle state changes, it is possible to +provide a URL endpoint to which the request should be made. +""" + +class Unset: ... + +UNSET = Unset() diff --git a/src/pact/v2/__init__.py b/src/pact/v2/__init__.py new file mode 100644 index 000000000..ebd8a8a85 --- /dev/null +++ b/src/pact/v2/__init__.py @@ -0,0 +1,43 @@ +"""Python methods for interactive with a Pact Mock Service.""" +import warnings + +from pact.v2.broker import Broker +from pact.v2.consumer import Consumer +from pact.v2.matchers import EachLike, Like, SomethingLike, Term, Format +from pact.v2.message_consumer import MessageConsumer +from pact.v2.message_pact import MessagePact +from pact.v2.message_provider import MessageProvider +from pact.v2.pact import Pact +from pact.v2.provider import Provider +from pact.v2.verifier import Verifier + +from pact.__version__ import __version__, __version_tuple__ + +warnings.warn( + "The `pact.v2` module is deprecated.", + stacklevel=1, + category=DeprecationWarning, +) + +__url__ = "https://github.com/pact-foundation/pact-python" +__license__ = "MIT" + +__all__ = [ + '__version__', + '__version_tuple__', + '__url__', + '__license__', + 'Broker', + 'Consumer', + 'EachLike', + 'Like', + 'MessageConsumer', + 'MessagePact', + 'MessageProvider', + 'Pact', + 'Provider', + 'SomethingLike', + 'Term', + 'Format', + 'Verifier', +] diff --git a/pact/broker.py b/src/pact/v2/broker.py similarity index 87% rename from pact/broker.py rename to src/pact/v2/broker.py index 644453361..229c33bbd 100644 --- a/pact/broker.py +++ b/src/pact/v2/broker.py @@ -2,12 +2,14 @@ from __future__ import unicode_literals import fnmatch +import logging import os from subprocess import Popen +from pact_cli import _telemetry_env + from .constants import BROKER_CLIENT_PATH -import logging log = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -94,9 +96,19 @@ def publish(self, consumer_name, version, pact_dir=None, if auto_detect_version_properties is True: command.append('--auto-detect-version-properties') - log.debug(f"PactBroker publish command: {command}") + if log.isEnabledFor(logging.DEBUG): + log_command: list[str] = [] + for arg in command: + if arg.startswith('--broker-password='): + log_command.append('--broker-password=') + elif arg.startswith('--broker-token='): + log_command.append('--broker-token=') + else: + log_command.append(arg) + + log.debug(f"PactBroker publish command: {log_command}") - publish_process = Popen(command) + publish_process = Popen(command, env=_telemetry_env()) publish_process.wait() if publish_process.returncode != 0: url = self._get_broker_base_url() diff --git a/pact/cli/__init__.py b/src/pact/v2/cli/__init__.py similarity index 100% rename from pact/cli/__init__.py rename to src/pact/v2/cli/__init__.py diff --git a/pact/cli/verify.py b/src/pact/v2/cli/verify.py similarity index 99% rename from pact/cli/verify.py rename to src/pact/v2/cli/verify.py index b388a5300..8c3f51762 100644 --- a/pact/cli/verify.py +++ b/src/pact/v2/cli/verify.py @@ -1,7 +1,7 @@ """Methods to verify previously created pacts.""" import sys -from pact.verify_wrapper import path_exists, expand_directories, VerifyWrapper +from pact.v2.verify_wrapper import path_exists, expand_directories, VerifyWrapper import click diff --git a/src/pact/v2/constants.py b/src/pact/v2/constants.py new file mode 100644 index 000000000..2caae7e84 --- /dev/null +++ b/src/pact/v2/constants.py @@ -0,0 +1,12 @@ +""" +Constant values for the pact-python package. + +The constants in this module are simply re-exported from the `pact_cli` module. +""" + +import pact_cli + +BROKER_CLIENT_PATH = pact_cli.BROKER_CLIENT_PATH or "" +MESSAGE_PATH = pact_cli.MESSAGE_PATH or "" +MOCK_SERVICE_PATH = pact_cli.MOCK_SERVICE_PATH or "" +VERIFIER_PATH = pact_cli.VERIFIER_PATH or "" diff --git a/pact/consumer.py b/src/pact/v2/consumer.py similarity index 96% rename from pact/consumer.py rename to src/pact/v2/consumer.py index e2357a024..64fbec064 100644 --- a/pact/consumer.py +++ b/src/pact/v2/consumer.py @@ -10,9 +10,9 @@ class Consumer(object): Use this class to describe the service making requests to the provider and then use `has_pact_with` to create a contract with a specific service: - >>> from pact import Consumer, Provider + >>> from pact.v2 import Consumer, Provider >>> consumer = Consumer('my-web-front-end') - >>> consumer.has_pact_with(Provider('my-backend-serivce')) + >>> consumer.has_pact_with(Provider('my-backend-service')) """ def __init__(self, name, service_cls=Pact, tags=None, @@ -69,10 +69,10 @@ def has_pact_with(self, provider, host_name='localhost', port=1234, If you are running the Pact mock service in a non-default location, you can provide the host name and port here: - >>> from pact import Consumer, Provider + >>> from pact.v2 import Consumer, Provider >>> consumer = Consumer('my-web-front-end') >>> consumer.has_pact_with( - ... Provider('my-backend-serivce'), + ... Provider('my-backend-service'), ... host_name='192.168.1.1', ... port=8000) @@ -84,7 +84,7 @@ def has_pact_with(self, provider, host_name='localhost', port=1234, `localhost`. :type host_name: str :param port: The TCP port to use when contacting the Pact mock service. - This will need to tbe the same port used by your code under test + This will need to be the same port used by your code under test to contact the mock service. It defaults to: 1234 :type port: int :param log_dir: The directory where logs should be written. Defaults to diff --git a/pact/http_proxy.py b/src/pact/v2/http_proxy.py similarity index 94% rename from pact/http_proxy.py rename to src/pact/v2/http_proxy.py index 23c9ebda9..5c85d99dd 100644 --- a/pact/http_proxy.py +++ b/src/pact/v2/http_proxy.py @@ -55,4 +55,4 @@ async def setup(request: Request): def run_proxy(): """Rub HTTP Proxy.""" - uvicorn.run("pact.http_proxy:app", port=PROXY_PORT, log_level=UVICORN_LOGGING_LEVEL) + uvicorn.run("pact.v2.http_proxy:app", port=PROXY_PORT, log_level=UVICORN_LOGGING_LEVEL) diff --git a/pact/matchers.py b/src/pact/v2/matchers.py similarity index 85% rename from pact/matchers.py rename to src/pact/v2/matchers.py index 52a414804..0ef0bbe37 100644 --- a/pact/matchers.py +++ b/src/pact/v2/matchers.py @@ -22,7 +22,7 @@ class EachLike(Matcher): Expect the data to be a list of similar objects. Example: - >>> from pact import Consumer, Provider + >>> from pact.v2 import Consumer, Provider >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) >>> (pact.given('there are three comments') ... .upon_receiving('a request for the most recent 2 comments') @@ -73,7 +73,7 @@ class Like(Matcher): Expect the type of the value to be the same as matcher. Example: - >>> from pact import Consumer, Provider + >>> from pact.v2 import Consumer, Provider >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) >>> (pact ... .given('there is a random number generator') @@ -131,7 +131,7 @@ class Term(Matcher): Expect the response to match a specified regular expression. Example: - >>> from pact import Consumer, Provider + >>> from pact.v2 import Consumer, Provider >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) >>> (pact.given('the current user is logged in as `tester`') ... .upon_receiving('a request for the user profile') @@ -234,8 +234,8 @@ class Format: Class of regular expressions for common formats. Example: - >>> from pact import Consumer, Provider - >>> from pact.matchers import Format + >>> from pact.v2 import Consumer, Provider + >>> from pact.v2.matchers import Format >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) >>> (pact.given('the current user is logged in as `tester`') ... .upon_receiving('a request for the user profile') @@ -264,6 +264,8 @@ def __init__(self): self.timestamp = self.timestamp() self.date = self.date() self.time = self.time() + self.iso_datetime = self.iso_8601_datetime() + self.iso_datetime_ms = self.iso_8601_datetime(with_ms=True) def integer_or_identifier(self): """ @@ -296,7 +298,7 @@ def hexadecimal(self): """ Match any hexadecimal. - :return: a Term object with a hexdecimal regex. + :return: a Term object with a hexadecimal regex. :rtype: Term """ return Term(self.Regexes.hexadecimal.value, '3F') @@ -360,6 +362,52 @@ def time(self): ).time().isoformat() ) + def iso_8601_datetime(self, with_ms=False): + """ + Match a string for a full ISO 8601 Date. + + Does not do any sort of date validation, only checks if the string is + according to the ISO 8601 spec. + + This method differs from :func:`~pact.Format.timestamp`, + :func:`~pact.Format.date` and :func:`~pact.Format.time` implementations + in that it is more stringent and tests the string for exact match to + the ISO 8601 dates format. + + Without `with_ms` will match string containing ISO 8601 formatted dates + as stated bellow: + + * 2016-12-15T20:16:01 + * 2010-05-01T01:14:31.876 + * 2016-05-24T15:54:14.00000Z + * 1994-11-05T08:15:30-05:00 + * 2002-01-31T23:00:00.1234-02:00 + * 1991-02-20T06:35:26.079043+00:00 + + Otherwise, ONLY dates with milliseconds will match the pattern: + + * 2010-05-01T01:14:31.876 + * 2016-05-24T15:54:14.00000Z + * 2002-01-31T23:00:00.1234-02:00 + * 1991-02-20T06:35:26.079043+00:00 + + :param with_ms: Enforcing millisecond precision. + :type with_ms: bool + :return: a Term object with a date regex. + :rtype: Term + """ + date = [1991, 2, 20, 6, 35, 26] + if with_ms: + matcher = self.Regexes.iso_8601_datetime_ms.value + date.append(79043) + else: + matcher = self.Regexes.iso_8601_datetime.value + + return Term( + matcher, + datetime.datetime(*date, tzinfo=datetime.timezone.utc).isoformat() + ) + class Regexes(Enum): """Regex Enum for common formats.""" @@ -398,3 +446,7 @@ class Regexes(Enum): r'0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|' \ r'[12]\d{2}|3([0-5]\d|6[1-6])))?)' time_regex = r'^(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?$' + iso_8601_datetime = r'^\d{4}-[01]\d-[0-3]\d\x54[0-2]\d:[0-6]\d:' \ + r'[0-6]\d(?:\.\d+)?(?:(?:[+-]\d\d:?\d\d)|\x5A)?$' + iso_8601_datetime_ms = r'^\d{4}-[01]\d-[0-3]\d\x54[0-2]\d:[0-6]\d:' \ + r'[0-6]\d\.\d+(?:(?:[+-]\d\d:?\d\d)|\x5A)?$' diff --git a/pact/message_consumer.py b/src/pact/v2/message_consumer.py similarity index 96% rename from pact/message_consumer.py rename to src/pact/v2/message_consumer.py index 97f1718b3..acbb5f141 100644 --- a/pact/message_consumer.py +++ b/src/pact/v2/message_consumer.py @@ -10,9 +10,9 @@ class MessageConsumer(object): Use this class to describe the service making requests to the provider and then use `has_pact_with` to create a contract with a specific service: - >>> from pact import MessageConsumer, Provider + >>> from pact.v2 import MessageConsumer, Provider >>> message_consumer = MessageConsumer('my-web-front-end') - >>> message_consumer.has_pact_with(Provider('my-backend-serivce')) + >>> message_consumer.has_pact_with(Provider('my-backend-service')) """ def __init__( @@ -83,10 +83,10 @@ def has_pact_with( If you are running the Pact mock service in a non-default location, you can provide the host name and port here: - >>> from pact import Consumer, Provider + >>> from pact.v2 import Consumer, Provider >>> consumer = Consumer('my-web-front-end') >>> consumer.has_pact_with( - ... Provider('my-backend-serivce'), + ... Provider('my-backend-service'), ... host_name='192.168.1.1', ... port=8000) diff --git a/pact/message_pact.py b/src/pact/v2/message_pact.py similarity index 98% rename from pact/message_pact.py rename to src/pact/v2/message_pact.py index 409d9de36..2eaee6ff0 100644 --- a/pact/message_pact.py +++ b/src/pact/v2/message_pact.py @@ -5,6 +5,8 @@ import os from subprocess import Popen +from pact_cli import _telemetry_env + from .broker import Broker from .constants import MESSAGE_PATH from .matchers import from_term @@ -16,7 +18,7 @@ class MessagePact(Broker): Provides Python context handler to perform tests on a Python consumer. For example: - >>> from pact import MessageConsumer, Provider + >>> from pact.v2 import MessageConsumer, Provider >>> pact = MessageConsumer('MyMessageConsumer').has_pact_with(Provider('provider')) >>> (pact ... .given({"name": "Test provider"}]) @@ -179,7 +181,7 @@ def write_to_pact_file(self): "--provider", f"{self.provider.name}", ] - self._message_process = Popen(command) + self._message_process = Popen(command, env=_telemetry_env()) self._message_process.wait() def _insert_message_if_complete(self): diff --git a/pact/message_provider.py b/src/pact/v2/message_provider.py similarity index 98% rename from pact/message_provider.py rename to src/pact/v2/message_provider.py index 4774fc84a..594b2943c 100644 --- a/pact/message_provider.py +++ b/src/pact/v2/message_provider.py @@ -147,7 +147,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): Exit a Python context. Return False to cascade the exception in context manager's body. - Otherwise it will be supressed and the test will always pass. + Otherwise it will be suppressed and the test will always pass. """ if (exc_type, exc_val, exc_tb) != (None, None, None): if exc_type is not None: diff --git a/pact/pact.py b/src/pact/v2/pact.py similarity index 98% rename from pact/pact.py rename to src/pact/v2/pact.py index 638d9de78..da5113217 100644 --- a/pact/pact.py +++ b/src/pact/v2/pact.py @@ -10,6 +10,8 @@ from requests.adapters import HTTPAdapter from requests.packages.urllib3 import Retry +from pact_cli import _telemetry_env + from .broker import Broker from .constants import MOCK_SERVICE_PATH from .matchers import from_term @@ -23,7 +25,7 @@ class Pact(Broker): Provides Python context handlers to configure the Pact mock service to perform tests on a Python consumer. For example: - >>> from pact import Consumer, Provider + >>> from pact.v2 import Consumer, Provider >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) >>> (pact.given('the echo service is available') ... .upon_receiving('a request is made to the echo service') @@ -214,8 +216,10 @@ def start_service(self): command.extend(['--sslcert', self.sslcert]) if self.sslkey: command.extend(['--sslkey', self.sslkey]) + if self.cors: + command.extend(['--cors']) - self._process = Popen(command) + self._process = Popen(command, env=_telemetry_env()) self._wait_for_server_start() def stop_service(self): @@ -374,6 +378,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): Calls the mock service to verify that all interactions occurred as expected, and has it write out the contracts to disk. """ + self._interactions = [] if (exc_type, exc_val, exc_tb) != (None, None, None): return diff --git a/pact/provider.py b/src/pact/v2/provider.py similarity index 100% rename from pact/provider.py rename to src/pact/v2/provider.py diff --git a/pact/verifier.py b/src/pact/v2/verifier.py similarity index 98% rename from pact/verifier.py rename to src/pact/v2/verifier.py index 4388e1095..d675d9539 100644 --- a/pact/verifier.py +++ b/src/pact/v2/verifier.py @@ -1,7 +1,7 @@ """Classes and methods to verify Contracts.""" import json -from pact.verify_wrapper import VerifyWrapper, path_exists, expand_directories +from pact.v2.verify_wrapper import VerifyWrapper, path_exists, expand_directories class Verifier(object): diff --git a/pact/verify_wrapper.py b/src/pact/v2/verify_wrapper.py similarity index 97% rename from pact/verify_wrapper.py rename to src/pact/v2/verify_wrapper.py index 0789b6aef..7ead8b01d 100644 --- a/pact/verify_wrapper.py +++ b/src/pact/v2/verify_wrapper.py @@ -1,13 +1,13 @@ """Wrapper to verify previously created pacts.""" -from pact.constants import VERIFIER_PATH -import sys import os import platform - import subprocess -from os.path import isdir, join, isfile +import sys from os import listdir +from os.path import isdir, isfile, join + +from pact_cli import VERIFIER_PATH, _telemetry_env def capture_logs(process, verbose): @@ -98,7 +98,7 @@ def rerun_command(): " PACT_PROVIDER_STATE=''" " {command}".format(command=' '.join(sys.argv))) - env = os.environ.copy() + env = _telemetry_env() env['PACT_INTERACTION_RERUN_COMMAND'] = command return env @@ -125,6 +125,9 @@ def __init__(self, *args, **kwargs): class VerifyWrapper(object): """A Pact Verifier Wrapper.""" + def __init__(self): + pass + def _broker_present(self, **kwargs): if kwargs.get('broker_url') is None: return False diff --git a/src/pact/verifier.py b/src/pact/verifier.py new file mode 100644 index 000000000..3a53a476a --- /dev/null +++ b/src/pact/verifier.py @@ -0,0 +1,1682 @@ +""" +Verifier for Pact. + +The Verifier is used to verify that a provider meets the expectations of a +consumer. This is done by replaying interactions from the consumer against the +provider, and ensuring that the provider's responses match the expectations set +by the consumer. + +The interactions to be verified can be sourced either from local Pact files or +from a Pact Broker. The Verifier can be configured to filter interactions based +on their description and state, and to set the provider information and +transports. + +When performing the verification, Pact will replay the interactions from the +consumer against the provider and ensure that the provider's responses match the +expectations set by the consumer. + +/// info +The interface provided by this module could be improved. If you have any +suggestions, please consider creating a new [GitHub +discussion](https://github.com/pact-foundation/pact-python/discussions) or +reaching out over [Slack](https://slack.pact.io). +/// + +## Usage + +The general usage of the Verifier is as follows: + +```python +from pact import Verifier + + +# In the case of local Pact files +verifier = ( + Verifier("My Provider") + .add_transport("http", url="http://localhost:8080") + .add_source("pact/to/pacts/") +) +verifier.verify() + +# In the case of a Pact Broker +verifier = ( + Verifier("My Provider") + .add_transport("http", url="http://localhost:8080") + .broker_source("https://broker.example.com/") +) +verifier.verify() +``` + +## State Handling + +In general, the consumer will write interactions assuming that the provider is +in a certain state. For example, a consumer requesting information about a user +with ID `123` will have specified `given("user with ID 123 exists")`. It is the +responsibility of the provider to ensure that this state is met before the +interaction is replayed. + +In order to change the provider's internal state, Pact relies on a callback +endpoint. The specific manner in which this endpoint is implemented is up to the +provider as it is highly dependent on the provider's architecture. + +One common approach is to define the endpoint during testing only, and for the +endpoint to [mock][unittest.mock] the expected calls to the database and/or +external services. This allows the provider to be tested in isolation from the +rest of the system, and assertions can be made about the calls made to the +endpoint. + +An alternative approach might be to run a dedicated service which is responsible +for writing to the database such that the provider can retrieve the expected +data. This approach is more complex, but could be useful in cases where test +databases are already in use. +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +from collections.abc import Callable, Mapping +from contextlib import nullcontext +from datetime import date +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, TypedDict, overload + +from yarl import URL + +import pact_ffi +from pact._server import MessageProducer, StateCallback +from pact._util import apply_args +from pact.types import ( + UNSET, + Message, + MessageProducerArgs, + StateHandlerArgs, + StateHandlerUrl, + Unset, +) + +if sys.version_info < (3, 13): + from typing_extensions import deprecated +else: + from warnings import deprecated + + +if TYPE_CHECKING: + from collections.abc import Iterable + + from typing_extensions import Self + +logger = logging.getLogger(__name__) + + +class _ProviderTransport(TypedDict): + """ + Provider transport information. + + When the verifier is set up, it needs to communicate with the Provider. This + is typically done over a single transport method (e.g., HTTP); however, Pact + _does_ support multiple transport methods. + + This dictionary is used to store information for each transport method and + is a reflection of Rust's [`ProviderTransport` + struct](https://github.com/pact-foundation/pact-reference/blob/b55407ef2be897d286af9330506219d17d2a746c/rust/pact_verifier/src/lib.rs#L168). + """ + + transport: str + """ + The transport method for payloads. + + This is typically one of `http` or `message`. Any other value is used as a + custom plugin (e.g., `grpc`). + """ + port: int | None + """ + The port on which the provider is listening. + """ + path: str | None + """ + The path under which the provider is listening. + + This is prefixed to all paths in interactions. For example, if the path is + `/api`, and the interaction path is `/users`, the request will be made to + `/api/users`. + """ + scheme: str | None + """ + The scheme to use for the provider. + + This is typically only used for the `http` transport method, where this + value can either be `http` or `https`. + """ + + +class Verifier: + """ + A Verifier between a consumer and a provider. + + This class encapsulates the logic for verifying that a provider meets the + expectations of a consumer. This is done by replaying interactions from the + consumer against the provider, and ensuring that the provider's responses + match the expectations set by the consumer. + """ + + def __init__(self, name: str, host: str | None = None) -> None: + """ + Create a new Verifier. + + Args: + name: + The name of the provider to verify. This is used to identify + which interactions the provider is involved in, and then Pact + will replay these interactions against the provider. + + host: + The host on which the Pact verifier is running. This is used to + communicate with the provider. If not specified, the default + value is `localhost`. + """ + self._name = name + self._host = host or "localhost" + self._handle = pact_ffi.verifier_new_for_application() + self._branch: str | None = None + + # In order to provide a fluent interface, we remember some options which + # are set using the same FFI method. In particular, we remember + # transport methods defined, and then before verification call the + # `set_info` and `add_transport` FFI methods as needed. + self._transports: list[_ProviderTransport] = [] + self._message_producer: ( + MessageProducer[Callable[..., Message]] | nullcontext[None] + ) = nullcontext() + self._state_handler: StateCallback[Callable[..., None]] | nullcontext[None] = ( + nullcontext() + ) + self._disable_ssl_verification = False + self._request_timeout = 5000 + # Using a broker source requires knowing the provider name, which is + # only provided to the FFI just before verification. As such, we store + # the broker source as a hook to be called just before verification, and + # after the provider name is set. + self._broker_source_hook: Callable[[], None] | None = None + + def __str__(self) -> str: + """ + Informal string representation of the Verifier. + """ + return f"Verifier({self._name})" + + def __repr__(self) -> str: + """ + Information-rish string representation of the Verifier. + """ + return f"" + + def add_transport( + self, + *, + url: str | URL | None = None, + protocol: str | None = None, + port: int | None = None, + path: str | None = None, + scheme: str | None = None, + ) -> Self: + """ + Add a provider transport method. + + If the provider supports multiple transport methods, or non-HTTP(S) + methods, this method allows these additional transport methods to be + added. It can be called multiple times to add multiple transport + methods. + + As some transport methods may not use ports, paths or schemes, these + parameters are optional. Note that while optional, these _may_ still be + used during testing as Pact uses HTTP(S) to communicate with the + provider. For example, if you are implementing your own message + verification, it needs to be exposed over HTTP and the `port` and `path` + arguments are used for this testing communication. + + Args: + url: + A convenient way to set the provider transport. This option + is mutually exclusive with the other options. + + protocol: + The protocol to use. This will typically be one of: + + - `http` for communications over HTTP(S) + + - `message` for non-plugin message-based communications + + Any other protocol will be treated as a custom protocol and will + be handled by a plugin. + + If `url` is _not_ specified, this parameter is required. + + port: + The provider port. + + If the protocol does not use ports, this parameter should be + `None`. If not specified, the default port for the scheme will + be used (provided the scheme is known). + + path: + The provider context path. + + For protocols which do not use paths, this parameter should be + `None`. + + For protocols which do use paths, this parameter should be + specified to avoid any ambiguity, though if left unspecified, + the root path will be used. + + If a non-root path is used, the path given here will be + prepended to the path in the interaction. For example, if the + path is `/api`, and the interaction path is `/users`, the + request will be made to `/api/users`. + + scheme: + The provider scheme, if applicable to the protocol. + + This is typically only used for the `http` protocol, where this + value can either be `http` (the default) or `https`. + + Raises: + ValueError: + If mutually exclusive parameters are provided, or required + parameters are missing, or host/protocol mismatches. + """ + if url and any(x is not None for x in (protocol, port, path, scheme)): + msg = "The `url` parameter is mutually exclusive with other parameters" + raise ValueError(msg) + + if url: + url = URL(url) + if url.host != self._host: + msg = f"Host mismatch: {url.host} != {self._host}" + raise ValueError(msg) + protocol = url.scheme + if protocol == "https": + protocol = "http" + port = url.port + path = url.path + scheme = url.scheme + return self.add_transport( + protocol=protocol, + port=port, + path=path, + scheme=scheme, + ) + + if not protocol: + msg = "A protocol must be specified" + raise ValueError(msg) + + if port is None and scheme: + if scheme.lower() == "http": + port = 80 + elif scheme.lower() == "https": + port = 443 + + logger.debug( + "Adding transport to verifier", + extra={ + "protocol": protocol, + "port": port, + "path": path, + "scheme": scheme, + }, + ) + self._transports.append( + _ProviderTransport( + transport=protocol, + port=port, + path=path, + scheme=scheme, + ) + ) + + return self + + def message_handler( + self, + handler: Callable[..., Message] + | dict[str, Callable[..., Message] | Message | bytes], + ) -> Self: + """ + Set the message handler. + + This method sets a custom message handler for the verifier. The handler + can be called to produce a specific message to send to the provider. + + This can be provided in one of two ways: + + 1. A fully fledged function that will be called for all messages. This + is the most powerful option as it allows for full control over the + message generation. The function's signature must be compatible with + the [`MessageProducerArgs`][types.MessageProducerArgs] type. + + 2. A dictionary mapping message names to either (a) producer functions, + (b) [`Message`][types.Message] dictionaries, or (c) raw bytes. If + using a producer function, it must be compatible with the + [`MessageProducerArgs`][types.MessageProducerArgs] type. + + ## Implementation + + There are a large number of ways to send messages, and the specifics of + the transport methods are not specifically relevant to Pact. As such, + Pact abstracts the transport layer away and uses a lightweight HTTP + server to handle messages. + + Pact Python is capable of setting up this server and handling the + messages internally using user-provided handlers. It is possible to use + your own HTTP server to handle messages by using the `add_transport` + method. It is not possible to use both this method and `add_transport` + to handle messages. + + Args: + handler: + The message handler. + + This should be a callable or a dictionary mapping message names + to callables, Message dicts, or bytes. + + Raises: + TypeError: + If the handler or its values are invalid. + """ + logger.debug( + "Setting message handler for verifier", + extra={ + "path": "/_pact/message", + }, + ) + + if callable(handler): + + def _handler( + name: str, + metadata: dict[str, Any] | None, + ) -> Message: + logger.info("Internal message produced called.") + return apply_args( + handler, + MessageProducerArgs(name=name, metadata=metadata), + ) + + self._message_producer = MessageProducer(_handler) + self.add_transport( + protocol="message", + port=self._message_producer.port, + path=self._message_producer.path, + ) + return self + + if isinstance(handler, dict): + + def _handler( + name: str, + metadata: dict[str, Any] | None, + ) -> Message: + logger.info("Internal message produced called.") + val = handler[name] + + if callable(val): + return apply_args( + val, + MessageProducerArgs(name=name, metadata=metadata), + ) + if isinstance(val, bytes): + return Message(contents=val, metadata=None, content_type=None) + if isinstance(val, dict): + return Message( + contents=val["contents"], + metadata=val.get("metadata"), + content_type=val.get("content_type"), + ) + + msg = "Invalid message handler value" + raise TypeError(msg) + + self._message_producer = MessageProducer(_handler) + self.add_transport( + protocol="message", + port=self._message_producer.port, + path=self._message_producer.path, + ) + + return self + + msg = "Invalid message handler type" + raise TypeError(msg) + + def filter( + self, + description: str | None = None, + *, + state: str | None = None, + no_state: bool = False, + ) -> Self: + """ + Set the filter for the interactions. + + This method can be used to filter interactions based on their + description and state. Repeated calls to this method will replace the + previous filter. + + Args: + description: + The interaction description. + + This should be a regular expression. If unspecified, no + filtering will be done based on the description. + + state: + The interaction state. + + This should be a regular expression. If unspecified, no + filtering will be done based on the state. + + no_state: + Whether to include interactions with no state. + """ + logger.debug( + "Setting filter for verifier", + extra={ + "description": description, + "state": state, + "no_state": no_state, + }, + ) + pact_ffi.verifier_set_filter_info( + self._handle, + description, + state, + filter_no_state=no_state, + ) + return self + + # Functional argument, either direct or via a dictionary. + @overload + def state_handler( + self, + handler: Callable[..., None], + *, + teardown: bool = False, + body: None = None, + ) -> Self: ... + @overload + def state_handler( + self, + handler: Mapping[str, Callable[..., None]], + *, + teardown: bool = False, + body: None = None, + ) -> Self: ... + # Cases where the handler takes a URL. The `body` argument is required in + # this case. + @overload + def state_handler( + self, + handler: StateHandlerUrl, + *, + teardown: bool = False, + body: bool, + ) -> Self: ... + + def state_handler( + self, + handler: Callable[..., None] + | Mapping[str, Callable[..., None]] + | StateHandlerUrl, + *, + teardown: bool = False, + body: bool | None = None, + ) -> Self: + """ + Set the state handler. + + In many interactions, the consumer will assume that the provider is in a + certain state. For example, a consumer requesting information about a + user with ID `123` will have specified `given("user with ID 123 + exists")`. + + The state handler is responsible for changing the provider's internal + state to match the expected state before the interaction is replayed. + + This can be done in one of three ways: + + 1. By providing a single function that will be called for all state + changes. + 2. By providing a mapping of state names to functions. + 3. By providing the URL endpoint to which the request should be made. + + The last option is more complicated as it requires the provider to be + able to handle the state change requests. The first two options handle + this internally and are the preferred options if the provider is written + in Python. + + The function signature must be compatible with the + [`StateHandlerArgs`][types.StateHandlerArgs]. If the function has + additional arguments, these must either have default values, or be + filled by using the [`partial`][functools.partial] function. + + Pact also uses a special state denoted with the empty string `""`. This + is used as a generic test setup/teardown handler. This key is optional + in dictionaries, but other implementation should ensure they can handle + (or safely ignore) this state name. + + Args: + handler: + The handler for the state changes. This can be one of the + following: + + - A single function that will be called for all state changes. + - A dictionary mapping state names to functions. + - A URL endpoint to which the request should be made. + + See above for more information on the function signature. + + teardown: + Whether to teardown the provider state after an interaction is + validated. + + body: + Whether to include the state change request in the body (`True`) + or in the query string (`False`). This must be left as `None` if + providing one or more handler functions; and it must be set to a + boolean if providing a URL. + + Raises: + ValueError: + If the handler/body combination is invalid. + + TypeError: + If the handler type is invalid. + """ + if isinstance(handler, StateHandlerUrl): + if body is None: + msg = "The `body` parameter must be a boolean when providing a URL" + raise ValueError(msg) + return self._state_handler_url(handler, teardown=teardown, body=body) + + if isinstance(handler, Mapping): + if body is not None: + msg = "The `body` parameter must be `None` when providing a dictionary" + raise ValueError(msg) + return self._state_handler_dict(handler, teardown=teardown) + + if callable(handler): + if body is not None: + msg = "The `body` parameter must be `None` when providing a function" + raise ValueError(msg) + return self._state_handler_function(handler, teardown=teardown) + + msg = "Invalid handler type" + raise TypeError(msg) + + def _state_handler_url( + self, + handler: StateHandlerUrl, + *, + teardown: bool, + body: bool, + ) -> Self: + """ + Set the state handler to a URL. + + This method is used to set the state handler to a URL endpoint. This + endpoint will be called to change the provider's state. + + Args: + handler: + The URL endpoint to which the request should be made. + + teardown: + Whether to teardown the provider state after an interaction is + validated. + + body: + Whether to include the state change request in the body (`True`) + or in the query string (`False`). + + Returns: + The verifier instance. + + Raises: + ValueError: + If the body parameter is not a boolean when providing a URL. + """ + logger.debug( + "Setting URL state handler for verifier", + extra={ + "handler": handler, + "teardown": teardown, + "body": body, + }, + ) + pact_ffi.verifier_set_provider_state( + self._handle, + str(handler), + teardown=teardown, + body=body, + ) + return self + + def _state_handler_dict( + self, + handler: Mapping[str, Callable[..., None]], + *, + teardown: bool, + ) -> Self: + """ + Set the state handler to a dictionary of functions. + + This method is used to set the state handler to a dictionary of functions. + Each function is called when the provider's state needs to be changed. + + Args: + handler: + The dictionary mapping state names to functions. If `teardown` + is `True`, the functions must take two arguments: the action and + the parameters. If `teardown` is `False`, the functions must take + one argument: the parameters. + + Note that the empty string `""` is used as a special key for a + generic test setup/teardown handler. If this key is absent, + these callbacks will be safely ignored. + + teardown: + Whether to teardown the provider state after an interaction is + validated. + + Returns: + The verifier instance. + + Raises: + TypeError: + If any value in the dictionary is not callable. + """ + if any(not callable(f) for f in handler.values()): + msg = "All values in the dictionary must be callable" + raise TypeError(msg) + + logger.debug( + "Setting dictionary state handler for verifier", + extra={ + "states": list(handler.keys()), + "teardown": teardown, + }, + ) + + def _handler( + state: str, + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, + ) -> None: + if state == "" and state not in handler: + return + + logger.debug( + "Calling state handler function for state %r", + state, + extra={ + "action": action, + "parameters": parameters, + }, + ) + + apply_args( + handler[state], + StateHandlerArgs(state=state, action=action, parameters=parameters), + ) + + self._state_handler = StateCallback(_handler) + pact_ffi.verifier_set_provider_state( + self._handle, + self._state_handler.url, + teardown=teardown, + body=True, + ) + + return self + + def _state_handler_function( + self, + handler: Callable[..., None], + *, + teardown: bool, + ) -> Self: + """ + Set the state handler to a single function. + + This method is used to set the state handler to a single function. This + function will be called when the provider's state needs to be changed. + + Args: + handler: + The function to call when the provider's state needs to be + changed. If `teardown` is `True`, the function must take three + arguments: the state, the action, and the parameters. If + `teardown` is `False`, the function must take two arguments: the + state and the parameters. + + teardown: + Whether to teardown the provider state after an interaction is + validated. + + Returns: + The verifier instance. + + Raises: + TypeError: + If the handler is not callable. + """ + logger.debug( + "Setting function state handler for verifier", + extra={ + "handler": handler, + "teardown": teardown, + }, + ) + + def _handler( + state: str, + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, + ) -> None: + apply_args( + handler, + StateHandlerArgs(state=state, action=action, parameters=parameters), + ) + + self._state_handler = StateCallback(_handler) + pact_ffi.verifier_set_provider_state( + self._handle, + self._state_handler.url, + teardown=teardown, + body=True, + ) + + return self + + def disable_ssl_verification(self) -> Self: + """ + Disable SSL verification. + """ + self._disable_ssl_verification = True + pact_ffi.verifier_set_verification_options( + self._handle, + disable_ssl_verification=self._disable_ssl_verification, + request_timeout=self._request_timeout, + ) + return self + + def set_request_timeout(self, timeout: int) -> Self: + """ + Set the request timeout. + + Args: + timeout: + The request timeout in milliseconds. + + Raises: + ValueError: + If the timeout is negative. + """ + if timeout < 0: + msg = "Request timeout must be a positive integer" + raise ValueError(msg) + + self._request_timeout = timeout + pact_ffi.verifier_set_verification_options( + self._handle, + disable_ssl_verification=self._disable_ssl_verification, + request_timeout=self._request_timeout, + ) + return self + + def set_coloured_output(self, *, enabled: bool = True) -> Self: + """ + Toggle coloured output. + """ + pact_ffi.verifier_set_coloured_output(self._handle, enabled=enabled) + return self + + def set_error_on_empty_pact(self, *, enabled: bool = True) -> Self: + """ + Toggle error on empty pact. + + If enabled, a Pact file with no interactions will cause the verifier to + return an error. If disabled, a Pact file with no interactions will be + ignored. + """ + pact_ffi.verifier_set_no_pacts_is_error(self._handle, enabled=enabled) + return self + + def set_publish_options( + self, + version: str, + url: str | None = None, + branch: str | None = None, + tags: list[str] | None = None, + ) -> Self: + """ + Set options used when publishing results to the Broker. + + Args: + version: + The provider version. + + url: + URL to the build which ran the verification. + + tags: + Collection of tags for the provider. + + branch: + Name of the branch used for verification. + + The first time a branch is set here or through + [`provider_branch`][BrokerSelectorBuilder.provider_branch], + the value will be saved and use as a default for both. + """ + if not self._branch and branch: + self._branch = branch + pact_ffi.verifier_set_publish_options( + self._handle, + version, + url, + tags or [], + branch or self._branch, + ) + return self + + def filter_consumers(self, *filters: str) -> Self: + """ + Filter the consumers. + + Args: + filters: + Filters to apply to the consumers. + """ + pact_ffi.verifier_set_consumer_filters(self._handle, filters) + return self + + def add_custom_header(self, name: str, value: str) -> Self: + """ + Add a customer header to the request. + + These headers are added to every request made to the provider. + + Args: + name: + The key of the header. + + value: + The value of the header. + """ + pact_ffi.verifier_add_custom_header(self._handle, name, value) + return self + + def add_custom_headers( + self, + headers: dict[str, str] | Iterable[tuple[str, str]], + ) -> Self: + """ + Add multiple customer headers to the request. + + These headers are added to every request made to the provider. + + Args: + headers: + The headers to add. + + The value can be: + - a dictionary of header key-value pairs + - an iterable of (key, value) tuples + """ + if isinstance(headers, dict): + headers = headers.items() + for name, value in headers: + self.add_custom_header(name, value) + return self + + def follow_redirects(self, follow: bool) -> Self: # noqa: FBT001 + """ + Set whether redirects should be followed. + + By default, the Pact verifier does follow redirects, testing the final + non-redirect response (mimicking the default behaviour of most HTTP + clients). + + In some cases, it may be desirable to test the redirect response itself, + in which case this method can be used to disable following redirects. + """ + pact_ffi.verifier_set_follow_redirects(self._handle, follow=follow) + return self + + @overload + def add_source( + self, + source: str | URL, + *, + username: str | None = None, + password: str | None = None, + ) -> Self: ... + + @overload + def add_source(self, source: str | URL, *, token: str | None = None) -> Self: ... + + @overload + def add_source(self, source: str | Path) -> Self: ... + + def add_source( + self, + source: str | Path | URL, + *, + username: str | None = None, + password: str | None = None, + token: str | None = None, + ) -> Self: + """ + Adds a source to the verifier. + + This will use one or more Pact files as the source of interactions to + verify. + + Args: + source: + The source of the interactions. This may be either of the following: + + - A local file path to a Pact file. + - A local file path to a directory containing Pact files. + - A URL to a Pact file. + + If using a URL, the `username` and `password` parameters can be + used to provide basic HTTP authentication, or the `token` + parameter can be used to provide bearer token authentication. + The `username` and `password` parameters can also be passed as + part of the URL. + + username: + The username to use for basic HTTP authentication. This is only + used when the source is a URL. + + password: + The password to use for basic HTTP authentication. This is only + used when the source is a URL. + + token: + The token to use for bearer token authentication. This is only + used when the source is a URL. Note that this is mutually + exclusive with `username` and `password`. + + Raises: + ValueError: + If the source scheme is invalid. + """ + if isinstance(source, Path): + return self._add_source_local(source) + + if isinstance(source, URL): + if source.scheme == "file": + return self._add_source_local(source.path) + + if source.scheme in ("http", "https"): + return self._add_source_remote( + source, + username=username, + password=password, + token=token, + ) + + msg = f"Invalid source scheme: {source.scheme}" + raise ValueError(msg) + + # Strings are ambiguous, so we need identify them as either local or + # remote. + if "://" in source: + return self._add_source_remote( + URL(source), + username=username, + password=password, + token=token, + ) + return self._add_source_local(source) + + def _add_source_local(self, source: str | Path) -> Self: + """ + Adds a local source to the verifier. + + This will use one or more Pact files as the source of interactions to + verify. + + Args: + source: + The source of the interactions. This may be either of the + following: + + - A local file path to a Pact file. + - A local file path to a directory containing Pact files. + + Raises: + ValueError: + If the source is not a file or directory. + """ + source = Path(source) + if source.is_dir(): + pact_ffi.verifier_add_directory_source(self._handle, str(source)) + return self + if source.is_file(): + pact_ffi.verifier_add_file_source(self._handle, str(source)) + return self + msg = f"Invalid source: {source}" + raise ValueError(msg) + + def _add_source_remote( + self, + url: str | URL, + *, + username: str | None = None, + password: str | None = None, + token: str | None = None, + ) -> Self: + """ + Add a remote source to the verifier. + + This will use a Pact file accessible over HTTP or HTTPS as the source of + interactions to verify. + + Args: + url: + The source of the interactions. This must be a URL to a Pact + file. The URL may contain a username and password for basic HTTP + authentication. + + username: + The username to use for basic HTTP authentication. If the source + is a URL containing a username, this parameter must be `None`. + + password: + The password to use for basic HTTP authentication. If the source + is a URL containing a password, this parameter must be `None`. + + token: + The token to use for bearer token authentication. This is + mutually exclusive with `username` and `password` (whether they + be specified through arguments, or embedded in the URL). + + Raises: + ValueError: + If mutually exclusive authentication parameters are provided. + """ + url = URL(url) + + if url.user and username: + msg = "Cannot specify both `username` and a username in the URL" + raise ValueError(msg) + username = url.user or username + + if url.password and password: + msg = "Cannot specify both `password` and a password in the URL" + raise ValueError(msg) + password = url.password or password + + if token and (username or password): + msg = "Cannot specify both `token` and `username`/`password`" + raise ValueError(msg) + + pact_ffi.verifier_url_source( + self._handle, + str(url.with_user(None).with_password(None)), + username, + password, + token, + ) + return self + + @overload + def broker_source( + self, + url: str | URL | Unset = UNSET, + *, + username: str | Unset = UNSET, + password: str | Unset = UNSET, + selector: Literal[False] = False, + use_env: bool = True, + ) -> Self: ... + + @overload + def broker_source( + self, + url: str | URL | None | Unset = UNSET, + *, + token: str | None | Unset = UNSET, + selector: Literal[False] = False, + use_env: bool = True, + ) -> Self: ... + + @overload + def broker_source( + self, + url: str | URL | None | Unset = UNSET, + *, + username: str | None | Unset = UNSET, + password: str | None | Unset = UNSET, + selector: Literal[True], + use_env: bool = True, + ) -> BrokerSelectorBuilder: ... + + @overload + def broker_source( + self, + url: str | URL | None | Unset = UNSET, + *, + token: str | None | Unset = UNSET, + selector: Literal[True], + use_env: bool = True, + ) -> BrokerSelectorBuilder: ... + + def broker_source( # noqa: PLR0913 + self, + url: str | URL | None | Unset = UNSET, + *, + username: str | None | Unset = UNSET, + password: str | None | Unset = UNSET, + token: str | None | Unset = UNSET, + selector: bool = False, + use_env: bool = True, + ) -> BrokerSelectorBuilder | Self: + """ + Adds a broker source to the verifier. + + If any of the values are `None`, the value will be read from the + environment variables unless the `use_env` parameter is set to `False`. + The known variables are: + + - `PACT_BROKER_BASE_URL` for the `url` parameter. + - `PACT_BROKER_USERNAME` for the `username` parameter. + - `PACT_BROKER_PASSWORD` for the `password` parameter. + - `PACT_BROKER_TOKEN` for the `token` parameter. + + By default, or if `selector=False`, this function returns the verifier + instance to allow for method chaining. If `selector=True` is given, this + function returns a [`BrokerSelectorBuilder`][BrokerSelectorBuilder] + instance which allows for further configuration of the broker source in + a fluent interface. The [`build()`][BrokerSelectorBuilder.build] call is + then used to finalise the broker source and return the verifier instance + for further configuration. + + Args: + url: + The broker URL. The URL may contain a username and password for + basic HTTP authentication. + + username: + The username to use for basic HTTP authentication. If the source + is a URL containing a username, this parameter must be `None`. + + password: + The password to use for basic HTTP authentication. If the source + is a URL containing a password, this parameter must be `None`. + + token: + The token to use for bearer token authentication. This is + mutually exclusive with `username` and `password` (whether they + be specified through arguments, or embedded in the URL). + + selector: + Whether to return a + [BrokerSelectorBuilder][BrokerSelectorBuilder] instance. The + builder instance allows for further configuration of the broker + source and must be finalised with a call to + [`build()`][BrokerSelectorBuilder.build]. + + use_env: + Whether to read missing values from the environment variables. + This is `True` by default which allows for easy configuration + from the standard Pact environment variables. In all cases, the + explicitly provided values take precedence over the environment + variables. + + Raises: + ValueError: + If mutually exclusive authentication parameters are provided. + """ + + def maybe_var(v: Any | Unset, env: str) -> str | None: # noqa: ANN401 + if isinstance(v, Unset): + return os.getenv(env) if use_env else None + return v + + url = maybe_var(url, "PACT_BROKER_BASE_URL") + if not url: + msg = "A broker URL must be provided" + raise ValueError(msg) + url = URL(url) + + username = maybe_var(username, "PACT_BROKER_USERNAME") + if url.user and username: + msg = "Cannot specify both `username` and a username in the URL" + raise ValueError(msg) + + password = maybe_var(password, "PACT_BROKER_PASSWORD") + if url.password and password: + msg = "Cannot specify both `password` and a password in the URL" + raise ValueError(msg) + + token = maybe_var(token, "PACT_BROKER_TOKEN") + if token and (username or password): + msg = "Cannot specify both `token` and `username`/`password`" + raise ValueError(msg) + + if selector: + return BrokerSelectorBuilder( + self, + str(url.with_user(None).with_password(None)), + username, + password, + token, + ) + + self._broker_source_hook = lambda: pact_ffi.verifier_broker_source( + self._handle, + str(url.with_user(None).with_password(None)), + username, + password, + token, + ) + + return self + + def verify(self) -> Self: + """ + Verify the interactions. + + Returns: + Whether the interactions were verified successfully. + + Raises: + RuntimeError: + If no transports have been set. + """ + if not self._transports: + msg = "No transports have been set" + raise RuntimeError(msg) + + first, *rest = self._transports + + pact_ffi.verifier_set_provider_info( + self._handle, + self._name, + first["scheme"], + self._host, + first["port"], + first["path"], + ) + + for transport in rest: + pact_ffi.verifier_add_provider_transport( + self._handle, + transport["transport"], + transport["port"] or 0, + transport["path"], + transport["scheme"], + ) + + if self._broker_source_hook: + self._broker_source_hook() + + with self._message_producer, self._state_handler: + pact_ffi.verifier_execute(self._handle) + logger.debug("Verifier executed") + + return self + + @property + def logs(self) -> str: + """ + Get the logs. + """ + return pact_ffi.verifier_logs(self._handle) + + @classmethod + def logs_for_provider(cls, provider: str) -> str: + """ + Get the logs for a provider. + """ + return pact_ffi.verifier_logs_for_provider(provider) + + def output(self, *, strip_ansi: bool = False) -> str: + """ + Get the output. + """ + return pact_ffi.verifier_output(self._handle, strip_ansi=strip_ansi) + + @property + def results(self) -> dict[str, Any]: + """ + Get the results. + """ + return json.loads(pact_ffi.verifier_json(self._handle)) + + +class BrokerSelectorBuilder: + """ + A Broker selector. + + This class encapsulates the logic for selecting Pacts from a Pact broker. + """ + + def __init__( + self, + verifier: Verifier, + url: str, + username: str | None, + password: str | None, + token: str | None, + ) -> None: + """ + Instantiate a new Broker Selector. + + This constructor should not be called directly. Instead, use the + `broker_source` method of the `Verifier` class with `selector=True`. + """ + self._verifier = verifier + self._url = url + self._username = username + self._password = password + self._token = token + + # If the instance is dropped without having the `build()` method called, + # raise a warning. + self._built = False + + self._include_pending: bool = False + "Whether to include pending Pacts." + + self._include_wip_since: date | None = None + "Whether to include work in progress Pacts since a given date." + + self._provider_tags: list[str] | None = None + "List of provider tags to match." + + self._provider_branch: str | None = None + "The provider branch." + + self._consumer_versions: list[str | dict[str, Any]] | None = None + "List of consumer version selectors." + + self._consumer_tags: list[str] | None = None + "List of consumer tags to match." + + def include_pending(self) -> Self: + """ + Include pending Pacts. + """ + self._include_pending = True + return self + + def exclude_pending(self) -> Self: + """ + Exclude pending Pacts. + """ + self._include_pending = False + return self + + def include_wip_since(self, d: str | date) -> Self: + """ + Include work in progress Pacts since a given date. + """ + if isinstance(d, str): + d = date.fromisoformat(d) + self._include_wip_since = d + return self + + def exclude_wip(self) -> Self: + """ + Exclude work in progress Pacts. + """ + self._include_wip_since = None + return self + + def provider_tags(self, *tags: str) -> Self: + """ + Set the provider tags. + """ + self._provider_tags = list(tags) + return self + + def provider_branch(self, branch: str) -> Self: + """ + Set the provider branch. + + The first time a branch is set here or through + [`set_publish_options`][Verifier.set_publish_options], the value will be + saved and use as a default for both. + """ + self._provider_branch = branch + if not self._verifier._branch: # type: ignore # noqa: PGH003, SLF001 + self._verifier._branch = branch # type: ignore # noqa: PGH003, SLF001 + return self + + def consumer_version( # noqa: PLR0913 + self, + *, + consumer: str | None = None, + tag: str | None = None, + fallback_tag: str | None = None, + latest: bool | None = None, + deployed_or_released: Literal[True] | None = None, + deployed: Literal[True] | None = None, + released: Literal[True] | None = None, + environment: str | None = None, + main_branch: Literal[True] | None = None, + branch: str | None = None, + matching_branch: Literal[True] | None = None, + fallback_branch: str | None = None, + ) -> Self: + """ + Add a consumer version selector. + + This method allows specifying consumer version selection criteria to + filter which consumer pacts are verified from the broker. + + This function can be called multiple times to add multiple selectors. + The resulting selectors are combined with a logical OR, meaning that + pacts matching any of the selectors will be included in the + verification. + + Args: + consumer: + Application name to filter the results on. + + Allows a selector to only be applied to a certain consumer. + + tag: + The tag name(s) of the consumer versions to get the pacts for. + + This field is still supported but it is recommended to use the + `branch` in preference now. + + fallback_tag: + The name of the tag to fallback to if the specified `tag` does + not exist. + + This is useful when the consumer and provider use matching + branch names to coordinate the development of new features. This + field is still supported but it is recommended to use two + separate selectors - one with the main branch name and one with + the feature branch name. + + latest: + Only select the latest (if false, this selects all pacts for a + tag). + + Used in conjunction with the tag property. If a tag is + specified, and latest is true, then the latest pact for each of + the consumers with that tag will be returned. If a tag is + specified and the latest flag is not set to true, all the pacts + with the specified tag will be returned. + + deployed_or_released: + Applications that have been deployed or released. + + If the key is specified, can only be set to `True`. Returns the + pacts for all versions of the consumer that are currently + deployed or released and currently supported in any environment. + Use of this selector requires that the deployment of the + consumer application is recorded in the Pact Broker using the + `pact-broker record-deployment` or `pact-broker record-release` + CLI. + + deployed: + Applications that have been deployed. + + If the key is specified, can only be set to `True`. Returns the + pacts for all versions of the consumer that are currently + deployed to any environment. Use of this selector requires that + the deployment of the consumer application is recorded in the + Pact Broker using the `pact-broker record-deployment` CLI. + + released: + Applications that have been released. + + If the key is specified, can only be set to `True`. Returns the + pacts for all versions of the consumer that are released and + currently supported in any environment. Use of this selector + requires that the deployment of the consumer application is + recorded in the Pact Broker using the `pact-broker + record-release` CLI. + + environment: + Applications in a given environment. + + The name of the environment containing the consumer versions for + which to return the pacts. Used to further qualify `{ + "deployed": true }` or `{ "released": true }`. Normally, this + would not be needed, as it is recommended to verify the pacts + for all currently deployed/currently supported released + versions. + + main_branch: + Applications with the default branch set in the broker. + + If the key is specified, can only be set to `True`. Return the + pacts for the configured `mainBranch` of each consumer. Use of + this selector requires that the consumer has configured the + `mainBranch` property, and has set a branch name when publishing + the pacts. + + branch: + Applications with the given branch. + + The branch name of the consumer versions to get the pacts for. + Use of this selector requires that the consumer has configured a + branch name when publishing the pacts. + + matching_branch: + Applications that match the provider version branch sent during + verification. + + If the key is specified, can only be set to `True`. When true, + returns the latest pact for any branch with the same name as the + specified `provider_version_branch`. + + fallback_branch: + Fallback branch if branch doesn't exist. + + The name of the branch to fallback to if the specified branch + does not exist. Use of this property is discouraged as it may + allow a pact to pass on a feature branch while breaking + backwards compatibility with the main branch, which is generally + not desired. It is better to use two separate consumer version + selectors, one with the main branch name, and one with the + feature branch name, rather than use this property. + + Returns: + The builder instance for method chaining. + """ + if self._consumer_versions is None: + self._consumer_versions = [] + + param_mapping = [ + ("consumer", consumer), + ("tag", tag), + ("fallbackTag", fallback_tag), + ("latest", latest), + ("deployedOrReleased", deployed_or_released), + ("deployed", deployed), + ("released", released), + ("environment", environment), + ("mainBranch", main_branch), + ("branch", branch), + ("matchingBranch", matching_branch), + ("fallbackBranch", fallback_branch), + ] + + self._consumer_versions.append({ + key: value for key, value in param_mapping if value is not None + }) + return self + + @deprecated("Use `consumer_version` method with keyword arguments instead.") + def consumer_versions(self, *versions: str) -> Self: + """ + Set the consumer versions. + """ + if self._consumer_versions is None: + self._consumer_versions = [] + self._consumer_versions.extend(versions) + return self + + def consumer_tags(self, *tags: str) -> Self: + """ + Set the consumer tags. + """ + self._consumer_tags = list(tags) + return self + + def build(self) -> Verifier: + """ + Build the Broker Selector. + + Returns: + The Verifier instance with the broker source added. + """ + consumer_versions = [ + json.dumps(cv) if not isinstance(cv, str) else cv + for cv in (self._consumer_versions or []) + ] + + self._verifier._broker_source_hook = ( # noqa: SLF001 + lambda: pact_ffi.verifier_broker_source_with_selectors( + self._verifier._handle, # noqa: SLF001 + self._url, + self._username, + self._password, + self._token, + self._include_pending, + self._include_wip_since, + self._provider_tags or [], + self._provider_branch or self._verifier._branch, # noqa: SLF001 + consumer_versions, + self._consumer_tags or [], + ) + ) + self._built = True + return self._verifier + + def __del__(self) -> None: + """ + Destructor for the Broker Selector. + + This destructor will raise a warning if the instance is dropped without + having the [`build()`][pact.verifier.BrokerSelectorBuilder.build] + method called. + """ + if not self._built: + msg = "BrokerSelectorBuilder was dropped before being built." + raise Warning(msg) diff --git a/src/pact/xml.py b/src/pact/xml.py new file mode 100644 index 000000000..27e114ba6 --- /dev/null +++ b/src/pact/xml.py @@ -0,0 +1,312 @@ +""" +XML body builder for Pact contract testing. + +This module provides builder functions for constructing XML request and response +bodies with embedded [Pact matchers][match]. It abstracts the FFI's +internal XML description format, so test authors only need to describe the XML +structure and attach matchers where desired. + +```python +from pact import match, xml + +response = xml.body( + xml.element( + "user", + xml.element("id", match.int(123)), + xml.element("name", match.str("Alice")), + ) +) +pact.with_body(response, content_type="application/xml") +``` + +The returned dict is passed to +[`with_body()`][interaction._base.Interaction.with_body] with +`content_type="application/xml"`. The Pact FFI detects the JSON-formatted body +and extracts matching rules automatically. + +Individual functions can also be imported directly: + +```python +from pact.xml import body, element + +response = body(element("user", element("id", match.int(123)))) +``` +""" + +from __future__ import annotations + +from typing import Any, TypeAlias, Union + +from pact.match.matcher import AbstractMatcher + +XmlContent: TypeAlias = Union[ + "XmlElement", "AbstractMatcher[Any]", str, int, float, bool +] +""" +Valid types for element children. + +These can be child elements, matchers (text with rules), or literal primitive +values (text without rules). +""" + + +class XmlElement: + """ + Represents an XML element for use in Pact body descriptions. + + Do not instantiate directly, use [`element()`][element] instead. + """ + + def __init__( + self, + tag: str, + *children: XmlContent, + attrs: dict[str, Any] | None = None, + ) -> None: + """ + Initialise an XML element. + + Args: + tag: + Element tag name, optionally including a namespace prefix + (e.g. `"ns1:project"`). + + *children: + Child elements or text content. Each positional argument is one + of: + + - [`XmlElement`][XmlElement]: a nested child element. + - [`AbstractMatcher`][match.matcher.AbstractMatcher]: text + content with a matching rule. The matcher's example value is + used as the generated text. + - `str`, `int`, `float`, or `bool`: literal text content with + no matching rule. + + attrs: + Element attributes as a mapping of attribute name to value. + Values may be plain primitives or + [`AbstractMatcher`][match.matcher.AbstractMatcher] + instances. Use this parameter for namespace declarations (e.g. + `{"xmlns:ns1": "http://..."}`) and any attribute whose name is + not a valid Python identifier. + """ + self._tag = tag + self._children = children + self._attrs: dict[str, Any] = dict(attrs) if attrs is not None else {} + self._repeated = False + self._min: int | None = None + self._max: int | None = None + self._examples: int = 1 + + def each( + self, + *, + min: int = 1, # noqa: A002 + max: int | None = None, # noqa: A002 + examples: int | None = None, + ) -> XmlElement: + """ + Mark this element as a type-matched repeating element. + + Causes a `type` matching rule to be registered for this element in the + generated pact file, meaning the provider must return at least `min` + instances of this element structure. Use `max` to also enforce an upper + bound. + + This method is typically used when the element appears as a child + inside another element's children list, but it can also be applied to + the root element passed to [`body()`][body]. + + Args: + min: + Minimum number of matching elements required in the provider + response. Defaults to `1`. + + max: + Maximum number of matching elements (optional). + + examples: + Number of example copies of this element generated in the pact + body. Defaults to `min`. + + Returns: + `self`, to allow chaining directly after the constructor call. + + Example: + ```python + xml.element( + "item", + xml.element("id", match.int(1)), + ).each(min=1, examples=3) + ``` + """ + if min < 1: + msg = f"min must be at least 1, got {min!r}." + raise ValueError(msg) + + if max is not None and max < min: + msg = ( + "max must be greater than or equal to min; " + f"got min={min!r}, max={max!r}." + ) + raise ValueError(msg) + + effective_examples = examples if examples is not None else min + + if effective_examples < min: + msg = ( + "examples must be greater than or equal to min; " + f"got min={min!r}, examples={effective_examples!r}." + ) + raise ValueError(msg) + + if max is not None and effective_examples > max: + msg = ( + "examples must be less than or equal to max when max is set; " + f"got max={max!r}, examples={effective_examples!r}." + ) + raise ValueError(msg) + + self._repeated = True + self._min = min + self._max = max + self._examples = effective_examples + return self + + def to_serializable_dict(self) -> dict[str, Any]: + """ + Convert this element to a serialisable dict. + + The returned dict is suitable for + [`IntegrationJSONEncoder`][match.matcher.IntegrationJSONEncoder]. + [`AbstractMatcher`][match.matcher.AbstractMatcher] objects within + it are left as Python objects so that `IntegrationJSONEncoder` + serialises them to their integration-JSON form when + [`with_body()`][interaction._base.Interaction.with_body] + calls `json.dumps`. + + If [`.each()`][XmlElement.each] was called on this element, + the returned dict is wrapped in the FFI type-matcher envelope + (`{"pact:matcher:type": "type", "value": ..., "examples": N}`). + + Returns: + A dict representing this element in the FFI XML description format. + """ + elem_dict = self._to_element_dict() + if self._repeated: + wrapper: dict[str, Any] = { + "pact:matcher:type": "type", + "value": elem_dict, + "examples": self._examples, + } + if self._min is not None: + wrapper["min"] = self._min + if self._max is not None: + wrapper["max"] = self._max + return wrapper + return elem_dict + + def _to_element_dict(self) -> dict[str, Any]: + """Return the raw element dict without any repetition wrapper.""" + children_list: list[dict[str, Any]] = [] + for child in self._children: + if isinstance(child, XmlElement): + children_list.append(child.to_serializable_dict()) + elif isinstance(child, AbstractMatcher): + entry: dict[str, Any] = {"matcher": child} + if child.has_value(): + entry["content"] = child.value # type: ignore[attr-defined] + children_list.append(entry) + else: + # Literal primitive, plain text content, no matching rule. + children_list.append({"content": child}) + + return { + "name": self._tag, + "children": children_list, + "attributes": self._attrs, + } + + +def element( + tag: str, + *children: XmlContent, + attrs: dict[str, Any] | None = None, +) -> XmlElement: + """ + Create an XML element for use in a Pact body description. + + Args: + tag: + Element tag name, optionally including a namespace prefix + (e.g. `"ns1:project"`). + + *children: + Child elements or text content. Each positional argument is one + of: + + - [`XmlElement`][XmlElement]: a nested child element. + - [`AbstractMatcher`][match.matcher.AbstractMatcher]: text + content with a matching rule. The example value is taken from the + matcher. + - `str`, `int`, `float`, or `bool`: literal text content with no + matching rule. + + attrs: + Element attributes as a mapping of attribute name to value. Values + may be [`AbstractMatcher`][match.matcher.AbstractMatcher] + instances or plain primitives. Required for namespace declarations + (e.g. `{"xmlns:ns1": "http://..."}`) and attribute names that are + not valid Python identifiers. + + Returns: + An [`XmlElement`][XmlElement] instance. + + Example: + ```python + from pact import match, xml + + xml.element( + "user", + xml.element("id", match.int(123)), + xml.element("name", match.str("Alice")), + attrs={"xmlns:ns1": "http://some.namespace/"}, + ) + ``` + """ + return XmlElement(tag, *children, attrs=attrs) + + +def body(root: XmlElement) -> dict[str, Any]: + """ + Wrap a root [`XmlElement`][XmlElement] as an XML body description. + + The returned dict is suitable for passing to + [`with_body()`][interaction._base.Interaction.with_body] + with `content_type="application/xml"`. The Pact FFI detects the + JSON-formatted body, generates the example XML, and extracts matching rules + into the contract file. + + Args: + root: + The root element of the XML body. + + Returns: + A dict in the FFI XML description format, ready for + `with_body(result, content_type="application/xml")`. + + Example: + ```python + from pact import match, xml + + response = xml.body( + xml.element( + "user", + xml.element("id", match.int(123)), + xml.element("name", match.str("Alice")), + ) + ) + pact.with_body(response, content_type="application/xml") + ``` + """ + return {"root": root.to_serializable_dict()} diff --git a/tests/.ruff.toml b/tests/.ruff.toml new file mode 100644 index 000000000..ee08a6db7 --- /dev/null +++ b/tests/.ruff.toml @@ -0,0 +1,17 @@ +#:schema https://www.schemastore.org/ruff.json + +extend = "../pyproject.toml" + +# We have a number of helper files which contain assertions/magic values, etc. + +[lint] +ignore = [ + "D102", # Require docstring in public methods + "D103", # Require docstring in public function + "D104", # Require docstring in public package + "INP001", # Forbid implicit namespaces + "PLR2004", # Forbid magic values + "RUF018", # Forbid assignment in assertions + "S101", # Forbid assert statements + "TID252", # Require absolute imports +] diff --git a/tests/assets/pacts/basic.json b/tests/assets/pacts/basic.json new file mode 100644 index 000000000..5c8fb325b --- /dev/null +++ b/tests/assets/pacts/basic.json @@ -0,0 +1,8 @@ +{ + "consumer": { "name": "Example Consumer" }, + "provider": { "name": "Example Producer" }, + "metadata": { + "pactSpecification": { "version": "2.0" } + }, + "interactions": [] +} diff --git a/tests/compatibility_suite/__init__.py b/tests/compatibility_suite/__init__.py new file mode 100644 index 000000000..16228019e --- /dev/null +++ b/tests/compatibility_suite/__init__.py @@ -0,0 +1,3 @@ +""" +Compatibility suite tests. +""" diff --git a/tests/compatibility_suite/conftest.py b/tests/compatibility_suite/conftest.py new file mode 100644 index 000000000..73b61d6c1 --- /dev/null +++ b/tests/compatibility_suite/conftest.py @@ -0,0 +1,86 @@ +""" +Pytest configuration. + +As the compatibility suite makes use of a submodule, we need to make sure the +submodule has been initialized before running the tests. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest +from testcontainers.compose import DockerCompose # type: ignore[import-untyped] +from yarl import URL + +from pact.verifier import Verifier + +if TYPE_CHECKING: + from collections.abc import Generator + + +@pytest.fixture(scope="session", autouse=True) +def _submodule_init() -> None: + """ + Initialize the compatibility suite Git submodule if required. + """ + # Locate the git execute + submodule_dir = Path(__file__).parent / "definition" + if submodule_dir.is_dir(): + return + + git_exec = shutil.which("git") + if git_exec is None: + msg = ( + "Submodule not initialized and git executable not found." + " Please initialize the submodule with `git submodule init`." + ) + raise RuntimeError(msg) + subprocess.check_call([git_exec, "submodule", "init"]) # noqa: S603 + + +@pytest.fixture +def verifier() -> Verifier: + """ + Provide a Pact verifier instance scoped to a single test. + + Returns: + Configurable verifier for compatibility suite scenarios. + """ + return Verifier("provider") + + +@pytest.fixture(scope="session") +def broker_url(request: pytest.FixtureRequest) -> Generator[URL, Any, None]: + """ + Yield the Pact Broker URL, starting a container when required. + + If Pytest has been started with an explicit `--broker-url` option, then that + URL is returned by this fixture; otherwise, a Pact Broker container is + launched to run tests against it. + + Args: + request: + Active pytest request object used to inspect command-line options. + + Yields: + Location of the Pact Broker to use for compatibility testing. + """ + broker_url: str | None = request.config.getoption("--broker-url") + + # If we have been given a broker URL, there's nothing more to do here and we + # can return early. + if broker_url: + yield URL(broker_url) + return + + with DockerCompose( + Path(__file__).parent / "util", + compose_file_name="pact-broker.yml", + pull=True, + ) as _: + yield URL("http://pactbroker:pactbroker@localhost:9292") + return diff --git a/tests/compatibility_suite/definition b/tests/compatibility_suite/definition new file mode 160000 index 000000000..b03375f00 --- /dev/null +++ b/tests/compatibility_suite/definition @@ -0,0 +1 @@ +Subproject commit b03375f00f5adf1346bd76edb0c7968cc0b495cf diff --git a/tests/compatibility_suite/test_v1_consumer.py b/tests/compatibility_suite/test_v1_consumer.py new file mode 100644 index 000000000..cdd191fcb --- /dev/null +++ b/tests/compatibility_suite/test_v1_consumer.py @@ -0,0 +1,359 @@ +"""Basic HTTP consumer feature tests.""" + +from __future__ import annotations + +import logging + +from pytest_bdd import given, parsers, scenario + +from tests.compatibility_suite.util import parse_horizontal_table +from tests.compatibility_suite.util.consumer import ( + a_response_is_returned, + request_n_is_made_to_the_mock_server, + request_n_is_made_to_the_mock_server_with_the_following_changes, + the_content_type_will_be_set_as, + the_mismatches_will_contain_a_mismatch_with_path_with_the_error, + the_mismatches_will_contain_a_mismatch_with_the_error, + the_mock_server_is_started_with_interactions, + the_mock_server_status_will_be, + the_mock_server_status_will_be_an_expected_but_not_received_error_for_interaction_n, + the_mock_server_status_will_be_an_unexpected_request_received_for_interaction_n, + the_mock_server_status_will_be_an_unexpected_request_received_for_path, + the_mock_server_status_will_be_mismatches, + the_mock_server_will_write_out_a_pact_file_for_the_interaction_when_done, + the_nth_interaction_request_content_type_will_be, + the_nth_interaction_request_query_parameters_will_be, + the_nth_interaction_request_will_be_for_method, + the_nth_interaction_request_will_contain_the_document, + the_nth_interaction_request_will_contain_the_header, + the_nth_interaction_will_contain_the_document, + the_pact_file_will_contain_n_interactions, + the_pact_test_is_done, + the_payload_will_contain_the_json_document, +) +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, +) + +logger = logging.getLogger(__name__) + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "When all requests are made to the mock server", +) +def test_when_all_requests_are_made_to_the_mock_server() -> None: + """ + When all requests are made to the mock server. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "When not all requests are made to the mock server", +) +def test_when_not_all_requests_are_made_to_the_mock_server() -> None: + """ + When not all requests are made to the mock server. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "When an unexpected request is made to the mock server", +) +def test_when_an_unexpected_request_is_made_to_the_mock_server() -> None: + """ + When an unexpected request is made to the mock server. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with query parameters", +) +def test_request_with_query_parameters() -> None: + """ + Request with query parameters. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with invalid query parameters", +) +def test_request_with_invalid_query_parameters() -> None: + """ + Request with invalid query parameters. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with invalid path", +) +def test_request_with_invalid_path() -> None: + """ + Request with invalid path. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with invalid method", +) +def test_request_with_invalid_method() -> None: + """ + Request with invalid method. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with headers", +) +def test_request_with_headers() -> None: + """ + Request with headers. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with invalid headers", +) +def test_request_with_invalid_headers() -> None: + """ + Request with invalid headers. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with body", +) +def test_request_with_body() -> None: + """ + Request with body. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with invalid body", +) +def test_request_with_invalid_body() -> None: + """ + Request with invalid body. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with the incorrect type of body contents", +) +def test_request_with_the_incorrect_type_of_body_contents() -> None: + """ + Request with the incorrect type of body contents. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with plain text body (positive case)", +) +def test_request_with_plain_text_body_positive_case() -> None: + """ + Request with plain text body (positive case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with plain text body (negative case)", +) +def test_request_with_plain_text_body_negative_case() -> None: + """ + Request with plain text body (negative case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with JSON body (positive case)", +) +def test_request_with_json_body_positive_case() -> None: + """ + Request with JSON body (positive case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with JSON body (negative case)", +) +def test_request_with_json_body_negative_case() -> None: + """ + Request with JSON body (negative case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with XML body (positive case)", +) +def test_request_with_xml_body_positive_case() -> None: + """ + Request with XML body (positive case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with XML body (negative case)", +) +def test_request_with_xml_body_negative_case() -> None: + """ + Request with XML body (negative case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a binary body (positive case)", +) +def test_request_with_a_binary_body_positive_case() -> None: + """ + Request with a binary body (positive case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a binary body (negative case)", +) +def test_request_with_a_binary_body_negative_case() -> None: + """ + Request with a binary body (negative case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a form post body (positive case)", +) +def test_request_with_a_form_post_body_positive_case() -> None: + """ + Request with a form post body (positive case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a form post body (negative case)", +) +def test_request_with_a_form_post_body_negative_case() -> None: + """ + Request with a form post body (negative case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a multipart body (positive case)", +) +def test_request_with_a_multipart_body_positive_case() -> None: + """ + Request with a multipart body (positive case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a multipart body (negative case)", +) +def test_request_with_a_multipart_body_negative_case() -> None: + """ + Request with a multipart body (negative case). + """ + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.parse("the following HTTP interactions have been defined:"), + target_fixture="interaction_definitions", +) +def the_following_http_interactions_have_been_defined( + datatable: list[list[str]], +) -> dict[int, InteractionDefinition]: + """ + Parse the HTTP interactions table into a dictionary. + + The table columns are expected to be: + + - No + - method + - path + - query + - headers + - body + - response + - response content + - response body + + The first row is ignored, as it is assumed to be the column headers. The + order of the columns is similarly ignored. + """ + logger.info("Parsing interaction definitions") + + # Check that the table is well-formed + definitions = parse_horizontal_table(datatable) + assert len(definitions[0]) == 9, f"Expected 9 columns, got {len(definitions[0])}" + assert "No" in definitions[0], "'No' column not found" + + # Parse the table into a more useful format + interactions: dict[int, InteractionDefinition] = {} + for row in definitions: + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] + return interactions + + +################################################################################ +## When +################################################################################ + +request_n_is_made_to_the_mock_server() +request_n_is_made_to_the_mock_server_with_the_following_changes() +the_mock_server_is_started_with_interactions("V1") +the_pact_test_is_done() + +################################################################################ +## Then +################################################################################ + +a_response_is_returned() +the_content_type_will_be_set_as() +the_mismatches_will_contain_a_mismatch_with_path_with_the_error() +the_mismatches_will_contain_a_mismatch_with_the_error() +the_mock_server_status_will_be() +the_mock_server_status_will_be_an_expected_but_not_received_error_for_interaction_n() +the_mock_server_status_will_be_an_unexpected_request_received_for_interaction_n() +the_mock_server_status_will_be_an_unexpected_request_received_for_path() +the_mock_server_status_will_be_mismatches() +the_mock_server_will_write_out_a_pact_file_for_the_interaction_when_done() +the_nth_interaction_request_content_type_will_be() +the_nth_interaction_request_query_parameters_will_be() +the_nth_interaction_request_will_be_for_method() +the_nth_interaction_request_will_contain_the_document() +the_nth_interaction_request_will_contain_the_header() +the_nth_interaction_will_contain_the_document() +the_pact_file_will_contain_n_interactions() +the_payload_will_contain_the_json_document() diff --git a/tests/compatibility_suite/test_v1_provider.py b/tests/compatibility_suite/test_v1_provider.py new file mode 100644 index 000000000..050618ecc --- /dev/null +++ b/tests/compatibility_suite/test_v1_provider.py @@ -0,0 +1,337 @@ +""" +Basic HTTP provider feature test. +""" + +from __future__ import annotations + +import logging + +import pytest +from pytest_bdd import given, parsers, scenario + +from tests.compatibility_suite.util import parse_horizontal_table +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, +) +from tests.compatibility_suite.util.provider import ( + a_failed_verification_result_will_be_published_back, + a_pact_file_for_interaction_is_to_be_verified, + a_pact_file_for_interaction_is_to_be_verified_from_a_pact_broker, + a_pact_file_for_interaction_is_to_be_verified_with_a_provider_state_defined, + a_provider_is_started_that_returns_the_responses_from_interactions, + a_provider_is_started_that_returns_the_responses_from_interactions_with_changes, + a_provider_state_callback_is_configured, + a_request_filter_is_configured_to_make_the_following_changes, + a_successful_verification_result_will_be_published_back, + a_verification_result_will_not_be_published_back, + a_warning_will_be_displayed_that_there_was_no_callback_configured, + publishing_of_verification_results_is_enabled, + the_provider_state_callback_will_be_called_after_the_verification_is_run, + the_provider_state_callback_will_be_called_before_the_verification_is_run, + the_provider_state_callback_will_not_receive_a_setup_call, + the_provider_state_callback_will_receive_a_setup_call, + the_request_to_the_provider_will_contain_the_header, + the_verification_is_run, + the_verification_results_will_contain_a_error, + the_verification_will_be_successful, +) + +logger = logging.getLogger(__name__) + + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V1/http_provider.feature", + "Verifying a simple HTTP request", +) +def test_verifying_a_simple_http_request() -> None: + """Verifying a simple HTTP request.""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Verifying multiple Pact files", +) +def test_verifying_multiple_pact_files() -> None: + """Verifying multiple Pact files.""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Incorrect request is made to provider", +) +def test_incorrect_request_is_made_to_provider() -> None: + """Incorrect request is made to provider.""" + + +@pytest.mark.container +@scenario( + "definition/features/V1/http_provider.feature", + "Verifying a simple HTTP request via a Pact broker", +) +def test_verifying_a_simple_http_request_via_a_pact_broker() -> None: + """Verifying a simple HTTP request via a Pact broker.""" + + +@pytest.mark.container +@scenario( + "definition/features/V1/http_provider.feature", + "Verifying a simple HTTP request via a Pact broker with publishing results enabled", +) +def test_verifying_a_simple_http_request_via_a_pact_broker_with_publishing() -> None: + """Verifying a simple HTTP request via a Pact broker with publishing.""" + + +@pytest.mark.container +@scenario( + "definition/features/V1/http_provider.feature", + "Verifying multiple Pact files via a Pact broker", +) +def test_verifying_multiple_pact_files_via_a_pact_broker() -> None: + """Verifying multiple Pact files via a Pact broker.""" + + +@pytest.mark.container +@scenario( + "definition/features/V1/http_provider.feature", + "Incorrect request is made to provider via a Pact broker", +) +def test_incorrect_request_is_made_to_provider_via_a_pact_broker() -> None: + """Incorrect request is made to provider via a Pact broker.""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Verifying an interaction with a defined provider state", +) +def test_verifying_an_interaction_with_a_defined_provider_state() -> None: + """Verifying an interaction with a defined provider state.""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Verifying an interaction with no defined provider state", +) +def test_verifying_an_interaction_with_no_defined_provider_state() -> None: + """Verifying an interaction with no defined provider state.""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Verifying an interaction where the provider state callback fails", +) +def test_verifying_an_interaction_where_the_provider_state_callback_fails() -> None: + """Verifying an interaction where the provider state callback fails.""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Verifying an interaction where a provider state callback is not configured", +) +def test_verifying_an_interaction_where_no_provider_state_callback_configured() -> None: + """Verifying an interaction where a provider state callback is not configured.""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Verifying a HTTP request with a request filter configured", +) +def test_verifying_a_http_request_with_a_request_filter_configured() -> None: + """Verifying a HTTP request with a request filter configured.""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Verifies the response status code", +) +def test_verifies_the_response_status_code() -> None: + """Verifies the response status code.""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Verifies the response headers", +) +def test_verifies_the_response_headers() -> None: + """Verifies the response headers.""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with plain text body (positive case)", +) +def test_response_with_plain_text_body_positive_case() -> None: + """Response with plain text body (positive case).""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with plain text body (negative case)", +) +def test_response_with_plain_text_body_negative_case() -> None: + """Response with plain text body (negative case).""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with JSON body (positive case)", +) +def test_response_with_json_body_positive_case() -> None: + """Response with JSON body (positive case).""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with JSON body (negative case)", +) +def test_response_with_json_body_negative_case() -> None: + """Response with JSON body (negative case).""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with XML body (positive case)", +) +def test_response_with_xml_body_positive_case() -> None: + """Response with XML body (positive case).""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with XML body (negative case)", +) +def test_response_with_xml_body_negative_case() -> None: + """Response with XML body (negative case).""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with binary body (positive case)", +) +def test_response_with_binary_body_positive_case() -> None: + """Response with binary body (positive case).""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with binary body (negative case)", +) +def test_response_with_binary_body_negative_case() -> None: + """Response with binary body (negative case).""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with form post body (positive case)", +) +def test_response_with_form_post_body_positive_case() -> None: + """Response with form post body (positive case).""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with form post body (negative case)", +) +def test_response_with_form_post_body_negative_case() -> None: + """Response with form post body (negative case).""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with multipart body (positive case)", +) +def test_response_with_multipart_body_positive_case() -> None: + """Response with multipart body (positive case).""" + + +@scenario( + "definition/features/V1/http_provider.feature", + "Response with multipart body (negative case)", +) +def test_response_with_multipart_body_negative_case() -> None: + """Response with multipart body (negative case).""" + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.parse("the following HTTP interactions have been defined:"), + target_fixture="interaction_definitions", +) +def the_following_http_interactions_have_been_defined( + datatable: list[list[str]], +) -> dict[int, InteractionDefinition]: + """ + Parse the HTTP interactions table into a dictionary. + + The table columns are expected to be: + + - No + - method + - path + - query + - headers + - body + - response + - response headers + - response content + - response body + + The first row is ignored, as it is assumed to be the column headers. The + order of the columns is similarly ignored. + """ + logger.info("Parsing interaction definitions") + + # Check that the table is well-formed + definitions = parse_horizontal_table(datatable) + assert len(definitions[0]) == 10, f"Expected 10 columns, got {len(definitions[0])}" + assert "No" in definitions[0], "'No' column not found" + + # Parse the table into a more useful format + interactions: dict[int, InteractionDefinition] = {} + for row in definitions: + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] + return interactions + + +a_pact_file_for_interaction_is_to_be_verified("V1") +a_pact_file_for_interaction_is_to_be_verified_from_a_pact_broker("V1") +a_pact_file_for_interaction_is_to_be_verified_with_a_provider_state_defined("V1") +a_provider_is_started_that_returns_the_responses_from_interactions() +a_provider_is_started_that_returns_the_responses_from_interactions_with_changes() +a_provider_state_callback_is_configured() +a_request_filter_is_configured_to_make_the_following_changes() +publishing_of_verification_results_is_enabled() + + +################################################################################ +## When +################################################################################ + + +the_verification_is_run() + + +################################################################################ +## Then +################################################################################ + + +a_failed_verification_result_will_be_published_back() +a_successful_verification_result_will_be_published_back() +a_verification_result_will_not_be_published_back() +a_warning_will_be_displayed_that_there_was_no_callback_configured() +the_provider_state_callback_will_be_called_after_the_verification_is_run() +the_provider_state_callback_will_be_called_before_the_verification_is_run() +the_provider_state_callback_will_not_receive_a_setup_call() +the_provider_state_callback_will_receive_a_setup_call() +the_request_to_the_provider_will_contain_the_header() +the_verification_results_will_contain_a_error() +the_verification_will_be_successful() diff --git a/tests/compatibility_suite/test_v2_consumer.py b/tests/compatibility_suite/test_v2_consumer.py new file mode 100644 index 000000000..9c89efa52 --- /dev/null +++ b/tests/compatibility_suite/test_v2_consumer.py @@ -0,0 +1,236 @@ +"""Basic HTTP consumer feature tests.""" + +from __future__ import annotations + +import logging + +from pytest_bdd import given, parsers, scenario + +from tests.compatibility_suite.util import parse_horizontal_table +from tests.compatibility_suite.util.consumer import ( + a_response_is_returned, + request_n_is_made_to_the_mock_server, + request_n_is_made_to_the_mock_server_with_the_following_changes, + the_content_type_will_be_set_as, + the_mismatches_will_contain_a_mismatch_with_path_with_the_error, + the_mismatches_will_contain_a_mismatch_with_the_error, + the_mock_server_is_started_with_interaction_n_but_with_the_following_changes, + the_mock_server_is_started_with_interactions, + the_mock_server_status_will_be, + the_mock_server_status_will_be_an_expected_but_not_received_error_for_interaction_n, + the_mock_server_status_will_be_an_unexpected_request_received_for_interaction_n, + the_mock_server_status_will_be_an_unexpected_request_received_for_path, + the_mock_server_status_will_be_mismatches, + the_mock_server_will_write_out_a_pact_file_for_the_interaction_when_done, + the_nth_interaction_request_content_type_will_be, + the_nth_interaction_request_query_parameters_will_be, + the_nth_interaction_request_will_be_for_method, + the_nth_interaction_request_will_contain_the_document, + the_nth_interaction_request_will_contain_the_header, + the_nth_interaction_will_contain_the_document, + the_pact_file_will_contain_n_interactions, + the_pact_test_is_done, + the_payload_will_contain_the_json_document, +) +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, +) + +logger = logging.getLogger(__name__) + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports a regex matcher (negative case)", +) +def test_supports_a_regex_matcher_negative_case() -> None: + """Supports a regex matcher (negative case).""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports a regex matcher (positive case)", +) +def test_supports_a_regex_matcher_positive_case() -> None: + """Supports a regex matcher (positive case).""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports a type matcher (negative case)", +) +def test_supports_a_type_matcher_negative_case() -> None: + """Supports a type matcher (negative case).""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports a type matcher (positive case)", +) +def test_supports_a_type_matcher_positive_case() -> None: + """Supports a type matcher (positive case).""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports matchers for repeated request headers (negative case)", +) +def test_supports_matchers_for_repeated_request_headers_negative_case() -> None: + """Supports matchers for repeated request headers (negative case).""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports matchers for repeated request headers (positive case)", +) +def test_supports_matchers_for_repeated_request_headers_positive_case() -> None: + """Supports matchers for repeated request headers (positive case).""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports matchers for repeated request query parameters (negative case)", +) +def test_supports_matchers_for_repeated_request_query_parameters_negative_case() -> ( + None +): + """Supports matchers for repeated request query parameters (negative case).""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports matchers for repeated request query parameters (positive case)", +) +def test_supports_matchers_for_repeated_request_query_parameters_positive_case() -> ( + None +): + """Supports matchers for repeated request query parameters (positive case).""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports matchers for request bodies", +) +def test_supports_matchers_for_request_bodies() -> None: + """Supports matchers for request bodies.""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports matchers for request headers", +) +def test_supports_matchers_for_request_headers() -> None: + """Supports matchers for request headers.""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports matchers for request query parameters", +) +def test_supports_matchers_for_request_query_parameters() -> None: + """Supports matchers for request query parameters.""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Type matchers cascade to children (negative case)", +) +def test_type_matchers_cascade_to_children_negative_case() -> None: + """Type matchers cascade to children (negative case).""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Type matchers cascade to children (positive case)", +) +def test_type_matchers_cascade_to_children_positive_case() -> None: + """Type matchers cascade to children (positive case).""" + + +@scenario( + "definition/features/V2/http_consumer.feature", + "Supports a matcher for request paths", +) +def test_supports_a_matcher_for_request_paths() -> None: + """Supports a matcher for request paths.""" + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.parse("the following HTTP interactions have been defined:"), + target_fixture="interaction_definitions", +) +def the_following_http_interactions_have_been_defined( + datatable: list[list[str]], +) -> dict[int, InteractionDefinition]: + """ + Parse the HTTP interactions table into a dictionary. + + The table columns are expected to be: + + - No + - method + - path + - query + - headers + - body + - matching rules + + The first row is ignored, as it is assumed to be the column headers. The + order of the columns is similarly ignored. + """ + logger.debug("Parsing interaction definitions") + + # Check that the table is well-formed + definitions = parse_horizontal_table(datatable) + assert len(definitions[0]) == 7, f"Expected 7 columns, got {len(definitions[0])}" + assert "No" in definitions[0], "'No' column not found" + + # Parse the table into a more useful format + interactions: dict[int, InteractionDefinition] = {} + for row in definitions: + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] + + return interactions + + +################################################################################ +## When +################################################################################ + +request_n_is_made_to_the_mock_server() +request_n_is_made_to_the_mock_server_with_the_following_changes() +the_mock_server_is_started_with_interactions("V2") +the_mock_server_is_started_with_interaction_n_but_with_the_following_changes("V2") +the_pact_test_is_done() + +################################################################################ +## Then +################################################################################ + +a_response_is_returned() +the_content_type_will_be_set_as() +the_mismatches_will_contain_a_mismatch_with_path_with_the_error() +the_mismatches_will_contain_a_mismatch_with_the_error() +the_mock_server_status_will_be() +the_mock_server_status_will_be_an_expected_but_not_received_error_for_interaction_n() +the_mock_server_status_will_be_an_unexpected_request_received_for_interaction_n() +the_mock_server_status_will_be_an_unexpected_request_received_for_path() +the_mock_server_status_will_be_mismatches() +the_mock_server_will_write_out_a_pact_file_for_the_interaction_when_done() +the_nth_interaction_request_content_type_will_be() +the_nth_interaction_request_query_parameters_will_be() +the_nth_interaction_request_will_be_for_method() +the_nth_interaction_request_will_contain_the_document() +the_nth_interaction_request_will_contain_the_header() +the_nth_interaction_will_contain_the_document() +the_pact_file_will_contain_n_interactions() +the_payload_will_contain_the_json_document() diff --git a/tests/compatibility_suite/test_v2_provider.py b/tests/compatibility_suite/test_v2_provider.py new file mode 100644 index 000000000..61170e89f --- /dev/null +++ b/tests/compatibility_suite/test_v2_provider.py @@ -0,0 +1,131 @@ +""" +Basic HTTP provider feature test. +""" + +from __future__ import annotations + +import logging + +from pytest_bdd import given, parsers, scenario + +from tests.compatibility_suite.util import parse_horizontal_table +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, +) +from tests.compatibility_suite.util.provider import ( + a_pact_file_for_interaction_is_to_be_verified, + a_provider_is_started_that_returns_the_responses_from_interactions_with_changes, + the_verification_is_run, + the_verification_results_will_contain_a_error, + the_verification_will_be_successful, +) + +logger = logging.getLogger(__name__) + + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V2/http_provider.feature", + "Supports matching rules for the response headers (positive case)", +) +def test_supports_matching_rules_for_the_response_headers_positive_case() -> None: + """ + Supports matching rules for the response headers (positive case). + """ + + +@scenario( + "definition/features/V2/http_provider.feature", + "Supports matching rules for the response headers (negative case)", +) +def test_supports_matching_rules_for_the_response_headers_negative_case() -> None: + """ + Supports matching rules for the response headers (negative case). + """ + + +@scenario( + "definition/features/V2/http_provider.feature", + "Verifies the response body (positive case)", +) +def test_verifies_the_response_body_positive_case() -> None: + """ + Verifies the response body (positive case). + """ + + +@scenario( + "definition/features/V2/http_provider.feature", + "Verifies the response body (negative case)", +) +def test_verifies_the_response_body_negative_case() -> None: + """ + Verifies the response body (negative case). + """ + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.parse("the following HTTP interactions have been defined:"), + target_fixture="interaction_definitions", +) +def the_following_http_interactions_have_been_defined( + datatable: list[list[str]], +) -> dict[int, InteractionDefinition]: + """ + Parse the HTTP interactions table into a dictionary. + + The table columns are expected to be: + + - No + - method + - path + - response + - response headers + - response content + - response body + - response matching rules + + The first row is ignored, as it is assumed to be the column headers. The + order of the columns is similarly ignored. + """ + logger.debug("Parsing interaction definitions") + + # Check that the table is well-formed + definitions = parse_horizontal_table(datatable) + assert len(definitions[0]) == 8, f"Expected 8 columns, got {len(definitions[0])}" + assert "No" in definitions[0], "'No' column not found" + + # Parse the table into a more useful format + interactions: dict[int, InteractionDefinition] = {} + for row in definitions: + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] + return interactions + + +a_pact_file_for_interaction_is_to_be_verified("V2") +a_provider_is_started_that_returns_the_responses_from_interactions_with_changes() + +################################################################################ +## When +################################################################################ + + +the_verification_is_run() + + +################################################################################ +## Then +################################################################################ + + +the_verification_results_will_contain_a_error() +the_verification_will_be_successful() diff --git a/tests/compatibility_suite/test_v3_consumer.py b/tests/compatibility_suite/test_v3_consumer.py new file mode 100644 index 000000000..19e044000 --- /dev/null +++ b/tests/compatibility_suite/test_v3_consumer.py @@ -0,0 +1,181 @@ +"""Basic HTTP consumer feature tests.""" + +from __future__ import annotations + +import json +import logging +import re +from typing import TYPE_CHECKING, Any + +from pytest_bdd import given, parsers, scenario, then + +from pact.pact import HttpInteraction, Pact +from tests.compatibility_suite.util import ( + PactInteractionTuple, + parse_horizontal_table, +) +from tests.compatibility_suite.util.consumer import ( + the_pact_file_for_the_test_is_generated, +) + +if TYPE_CHECKING: + from collections.abc import Generator + +logger = logging.getLogger(__name__) + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V3/http_consumer.feature", + "Supports specifying multiple provider states", +) +def test_supports_specifying_multiple_provider_states() -> None: + """Supports specifying multiple provider states.""" + + +@scenario( + "definition/features/V3/http_consumer.feature", + "Supports data for provider states", +) +def test_supports_data_for_provider_states() -> None: + """Supports data for provider states.""" + + +################################################################################ +## Given +################################################################################ + + +@given( + "an integration is being defined for a consumer test", + target_fixture="pact_interaction", +) +def an_integration_is_being_defined_for_a_consumer_test() -> Generator[ + PactInteractionTuple[HttpInteraction], Any, None +]: + """An integration is being defined for a consumer test.""" + pact = Pact("consumer", "provider") + pact.with_specification("V3") + yield PactInteractionTuple(pact, pact.upon_receiving("a request")) + + +@given(parsers.re(r'a provider state "(?P[^"]+)" is specified')) +def a_provider_state_is_specified( + pact_interaction: PactInteractionTuple[HttpInteraction], + state: str, +) -> None: + """A provider state is specified.""" + pact_interaction.interaction.given(state) + + +@given( + parsers.re( + r'a provider state "(?P[^"]+)" is specified' + r" with the following data:", + re.DOTALL, + ), +) +def a_provider_state_is_specified_with_the_following_data( + pact_interaction: PactInteractionTuple[HttpInteraction], + state: str, + datatable: list[list[str]], +) -> None: + """A provider state is specified.""" + data: list[dict[str, Any]] = parse_horizontal_table(datatable) + for row in data: + for key, value in row.items(): + if value.startswith('"') and value.endswith('"'): + row[key] = value[1:-1] + for key, value in row.items(): + if value == "true": + row[key] = True + elif value == "false": + row[key] = False + elif value.isdigit(): + row[key] = int(value) + elif value.replace(".", "", 1).isdigit(): + row[key] = float(value) + + pact_interaction.interaction.given(state, data[0]) + + +################################################################################ +## When +################################################################################ + + +the_pact_file_for_the_test_is_generated() + + +################################################################################ +## Then +################################################################################ + + +@then( + parsers.re( + r"the interaction in the Pact file will contain" + r" (?P\d+) provider states?" + ), + converters={"num": int}, +) +def the_interaction_in_the_pact_file_will_container_provider_states( + num: int, + pact_data: dict[str, Any], +) -> None: + """The interaction in the Pact file will container provider states.""" + assert "interactions" in pact_data + assert len(pact_data["interactions"]) == 1 + assert "providerStates" in pact_data["interactions"][0] + assert len(pact_data["interactions"][0]["providerStates"]) == num + + +@then( + parsers.re( + r"the interaction in the Pact file will contain" + r' provider state "(?P[^"]+)"' + ), +) +def the_interaction_in_the_pact_file_will_container_provider_state( + state: str, + pact_data: dict[str, Any], +) -> None: + """The interaction in the Pact file will container provider state.""" + assert "interactions" in pact_data + assert len(pact_data["interactions"]) == 1 + assert "providerStates" in pact_data["interactions"][0] + + assert any( + status["name"] == state + for status in pact_data["interactions"][0]["providerStates"] + ) + + +@then( + parsers.re( + r'the provider state "(?P[^"]+)" in the Pact file' + r" will contain the following parameters:", + re.DOTALL, + ), +) +def the_provider_state_in_the_pact_file_will_contain_the_following_parameters( + state: str, + pact_data: dict[str, Any], + datatable: list[list[str]], +) -> None: + """The provider state in the Pact file will contain the following parameters.""" + assert "interactions" in pact_data + assert len(pact_data["interactions"]) == 1 + assert "providerStates" in pact_data["interactions"][0] + table = parse_horizontal_table(datatable) + parameters: dict[str, Any] = json.loads(table[0]["parameters"]) + + provider_state = next( + status + for status in pact_data["interactions"][0]["providerStates"] + if status["name"] == state + ) + assert provider_state["params"] == parameters diff --git a/tests/compatibility_suite/test_v3_generators.py b/tests/compatibility_suite/test_v3_generators.py new file mode 100644 index 000000000..7d4053c70 --- /dev/null +++ b/tests/compatibility_suite/test_v3_generators.py @@ -0,0 +1,207 @@ +"""Test of V3 generators.""" + +from __future__ import annotations + +import logging +import re +from typing import TYPE_CHECKING + +import pytest +import requests +from pytest_bdd import ( + given, + parsers, + scenario, + then, + when, +) + +from pact import Pact +from tests.compatibility_suite.util import parse_horizontal_table +from tests.compatibility_suite.util.interaction_definition import InteractionDefinition + +if TYPE_CHECKING: + from collections.abc import Callable + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def pact() -> Pact: + return Pact( + "v3-generators-consumer", + "v3-generators-provider", + ).with_specification("V3") + + +################################################################################ +## Scenario +################################################################################ + + +@scenario("definition/features/V3/generators.feature", "Supports a UUID generator") +def test_supports_a_uuid_generator() -> None: + """Supports a UUID generator.""" + + +@scenario("definition/features/V3/generators.feature", "Supports a boolean generator") +def test_supports_a_boolean_generator() -> None: + """Supports a boolean generator.""" + + +@scenario("definition/features/V3/generators.feature", "Supports a date generator") +def test_supports_a_date_generator() -> None: + """Supports a date generator.""" + + +@scenario("definition/features/V3/generators.feature", "Supports a date-time generator") +def test_supports_a_datetime_generator() -> None: + """Supports a date-time generator.""" + + +@scenario( + "definition/features/V3/generators.feature", "Supports a random decimal generator" +) +def test_supports_a_random_decimal_generator() -> None: + """Supports a random decimal generator.""" + + +@scenario( + "definition/features/V3/generators.feature", + "Supports a random hexadecimal generator", +) +def test_supports_a_random_hexadecimal_generator() -> None: + """Supports a random hexadecimal generator.""" + + +@scenario( + "definition/features/V3/generators.feature", "Supports a random integer generator" +) +def test_supports_a_random_integer_generator() -> None: + """Supports a random integer generator.""" + + +@scenario( + "definition/features/V3/generators.feature", "Supports a random string generator" +) +def test_supports_a_random_string_generator() -> None: + """Supports a random string generator.""" + + +@scenario("definition/features/V3/generators.feature", "Supports a regex generator") +def test_supports_a_regex_generator() -> None: + """Supports a regex generator.""" + + +@scenario("definition/features/V3/generators.feature", "Supports a time generator") +def test_supports_a_time_generator() -> None: + """Supports a time generator.""" + + +################################################################################ +## Given +################################################################################ + + +@given( + "a request configured with the following generators:", + target_fixture="interaction", +) +def a_request_configured_with_the_following_generators( + pact: Pact, + datatable: list[list[str]], +) -> InteractionDefinition: + """A request configured with the following generators.""" + data = parse_horizontal_table(datatable) + assert len(data) == 1, "Expected a single row of data" + row = data[0] + + # These tests only define the response + row["response body"] = row.pop("body") + row["response generators"] = row.pop("generators") + + interaction = InteractionDefinition( + method="GET", + path="/", + response="200", + **row, # type: ignore[arg-type] + ) + interaction.add_to_pact(pact, "a generators request") + return interaction + + +################################################################################ +## When +################################################################################ + + +@when("the request is prepared for use", target_fixture="response") +def the_request_is_prepared_for_use(pact: Pact) -> requests.Response: + """The request is prepared for use.""" + with pact.serve() as srv: + response = requests.get( + str(srv.url), + timeout=2, + ) + response.raise_for_status() + + return response + + +################################################################################ +## Then +################################################################################ + +GENERATOR_PATTERN: dict[str, Callable[[object], bool]] = { + "UUID": lambda v: ( + isinstance(v, str) + and re.match( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", v + ) + is not None + ), + "boolean": lambda v: isinstance(v, bool), + "date": lambda v: ( + isinstance(v, str) and re.match(r"^\d{4}-\d{2}-\d{2}$", v) is not None + ), + "time": lambda v: ( + isinstance(v, str) and re.match(r"^\d{2}:\d{2}:\d{2}(\.\d+)?$", v) is not None + ), + "date-time": lambda v: ( + isinstance(v, str) + and re.match( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$", v + ) + is not None + ), + "decimal number": lambda v: ( + isinstance(v, str) and re.match(r"^-?\d+\.\d+$", v) is not None + ), + "hexadecimal number": lambda v: ( + isinstance(v, str) and re.match(r"^[0-9a-fA-F]+$", v) is not None + ), + "integer": lambda v: isinstance(v, int) or (isinstance(v, str) and v.isdigit()), + "random string": lambda v: isinstance(v, str) and len(v) > 0, + "string from the regex": lambda v: ( + isinstance(v, str) and re.match(r"^\d{1,8}$", v) is not None + ), +} + + +@then( + parsers.re( + r'the body value for "\$\.one" ' + r'will have been replaced with a "(?P[^"]+)"' + ) +) +def the_body_value_will_have_been_replaced( + response: requests.Response, + value: str, +) -> None: + """The body value for "$.one" will have been replaced with a value.""" + data = response.json() + logger.info("Response JSON: %r", data) + + assert "one" in data, 'Response body does not contain key "one"' + assert value in GENERATOR_PATTERN, f"Unknown generator type {value!r}" + assert GENERATOR_PATTERN[value](data["one"]) diff --git a/tests/compatibility_suite/test_v3_http_generators.py b/tests/compatibility_suite/test_v3_http_generators.py new file mode 100644 index 000000000..7a5c36526 --- /dev/null +++ b/tests/compatibility_suite/test_v3_http_generators.py @@ -0,0 +1,314 @@ +"""Test V3 HTTP generators.""" + +from __future__ import annotations + +import contextlib +import json +import re +from typing import TYPE_CHECKING, Literal +from urllib.parse import parse_qs + +import pytest +import requests +from pytest_bdd import ( + given, + parsers, + scenario, + then, + when, +) + +from pact import Pact, Verifier +from tests.compatibility_suite.util import parse_horizontal_table +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, + InteractionState, +) +from tests.compatibility_suite.util.provider import Provider + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +@pytest.fixture +def pact() -> Pact: + return Pact( + "v3-http-generators-consumer", + "v3-http-generators-provider", + ).with_specification("V3") + + +@pytest.fixture +def verifier() -> Verifier: + return Verifier("v3-http-generators-provider") + + +@pytest.fixture +def response() -> requests.Response | None: + """ + Default response, which gets overridden when needed. + """ + return None + + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V3/http_generators.feature", + "Supports using a generator with the request body", +) +def test_supports_using_a_generator_with_the_request_body() -> None: + """Supports using a generator with the request body.""" + + +@scenario( + "definition/features/V3/http_generators.feature", + "Supports using a generator with the request headers", +) +def test_supports_using_a_generator_with_the_request_headers() -> None: + """Supports using a generator with the request headers.""" + + +@scenario( + "definition/features/V3/http_generators.feature", + "Supports using a generator with the request path", +) +def test_supports_using_a_generator_with_the_request_path() -> None: + """Supports using a generator with the request path.""" + + +@scenario( + "definition/features/V3/http_generators.feature", + "Supports using a generator with the request query parameters", +) +def test_supports_using_a_generator_with_the_request_query_parameters() -> None: + """Supports using a generator with the request query parameters.""" + + +@scenario( + "definition/features/V3/http_generators.feature", + "Supports using a generator with the response body", +) +def test_supports_using_a_generator_with_the_response_body() -> None: + """Supports using a generator with the response body.""" + + +@scenario( + "definition/features/V3/http_generators.feature", + "Supports using a generator with the response headers", +) +def test_supports_using_a_generator_with_the_response_headers() -> None: + """Supports using a generator with the response headers.""" + + +@scenario( + "definition/features/V3/http_generators.feature", + "Supports using a generator with the response status", +) +def test_supports_using_a_generator_with_the_response_status() -> None: + """Supports using a generator with the response status.""" + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.re( + r"a (?Prequest|response) configured with the following generators:" + ), + target_fixture="provider", +) +def a_request_configured_with_the_following_generators( + part: Literal["request", "response"], + tmp_path: Path, + pact: Pact, + verifier: Verifier, + datatable: list[list[str]], +) -> Provider: + """A request configured with the following generators.""" + data = parse_horizontal_table(datatable) + assert len(data) == 1, "Expected a single row of data" + row: dict[str, str | None] = data[0] # type: ignore[assignment] + + if part == "response": + row["response generators"] = row.pop("generators") + if body := row.pop("body", None): + row["response body"] = body + + interaction = InteractionDefinition( + method="POST", + path="/", + response="200", + **row, # type: ignore[arg-type] + ) + interaction.states.append(InteractionState("a provider state exists", {"id": 1000})) + interaction.add_to_pact(pact, "a generators request") + + provider = Provider() + provider.add_interaction(interaction) + + pacts_path = tmp_path / "pacts" + pacts_path.mkdir(exist_ok=True, parents=True) + pact.write_file(pacts_path) + verifier.add_source(pacts_path) + + # with provider: + # yield provider + return provider + + +@given('the generator test mode is set as "Provider"') +def the_generator_test_mode_is_set_as_provider() -> None: + """The generator test mode is set as "Provider".""" + + +################################################################################ +## When +################################################################################ + + +@when(parsers.re("the request is prepared for use.*")) +def the_request_is_prepared_for_use( + verifier: Verifier, + provider: Provider, +) -> None: + """The request is prepared for use.""" + verifier.add_transport(url=provider.url) + + with provider, contextlib.suppress(RuntimeError): + verifier.verify() + + +@when("the response is prepared for use", target_fixture="response") +def the_response_is_prepared_for_use( + pact: Pact, +) -> requests.Response: + """The response is prepared for use.""" + with pact.serve() as srv: + return requests.post( + str(srv.url), + json={"one": "1"}, + headers={"Content-Type": "application/json"}, + timeout=2, + ) + + +################################################################################ +## Then +################################################################################ + + +GENERATOR_PATTERN: dict[str, Callable[[object], bool]] = { + "integer": lambda v: isinstance(v, int) or (isinstance(v, str) and v.isdigit()), +} + + +@then( + parsers.re( + r'the body value for "\$\.one" ' + r'will have been replaced with an? "(?P[^"]+)"' + ) +) +def the_body_value_will_have_been_replaced( + value: str, + provider: Provider, + response: requests.Response | None, +) -> None: + """The body value for "$.one" will have been replaced with a value.""" + assert provider.requests or response + + if provider.requests: + request = provider.requests[0] + + assert (body := request["body"]) + assert (data := json.loads(body)) + assert (one := data.get("one")) + assert value in GENERATOR_PATTERN + assert GENERATOR_PATTERN[value](one) + + if response: + data = response.json() + assert "one" in data + assert value in GENERATOR_PATTERN + assert GENERATOR_PATTERN[value](data["one"]) + + +@then( + parsers.re( + r'the request "(?PqueryParameter|header)\[(?P[^\]]+)\]" ' + r'will match "(?P[^"]+)"' + ) +) +def the_request_header_will_match( + part: Literal["queryParameter", "header"], + name: str, + pattern: str, + provider: Provider, +) -> None: + """The request header will match.""" + assert provider.requests + request = provider.requests[0] + + if part == "queryParameter": + assert (query := request["query"]) + query_dict = parse_qs(query) + assert (value := query_dict.get(name)) + assert re.match(pattern, value[0]) + return + + if part == "header": + assert (headers := request["headers"]) + assert (value := headers.get(name)) # type: ignore[assignment] + assert value is not None + assert re.match(pattern, value) + + +@then( + parsers.re( + r'the response "header\[(?P[^\]]+)\]" will match "(?P[^"]+)"' + ) +) +def the_response_header_will_match( + name: str, + pattern: str, + response: requests.Response | None, +) -> None: + """The response header will match the given pattern.""" + assert response is not None + value = response.headers.get(name) + assert value is not None + assert re.match(pattern, value) + + +@then(parsers.re(r'the request "path" will be set as "(?P[^"]+)"')) +def the_request_path_will_be_set_as(path: str, provider: Provider) -> None: + """The request "path" will be set as "/path/1000".""" + assert provider.requests + request = provider.requests[0] + + assert request.get("path") == path + + +@then(parsers.re(r'the response "status" will match "(?P[^"]+)"')) +def the_response_status_will_match( + pattern: str, response: requests.Response | None +) -> None: + """The response "status" will match a given pattern.""" + assert response is not None + status_code = str(response.status_code) + assert re.match(pattern, status_code) + + +@then(parsers.re(r'the response "status" will not be "(?P\d+)"')) +def the_response_status_will_not_be_200( + status: str, response: requests.Response | None +) -> None: + """The response "status" will not be the given status.""" + assert response is not None + assert str(response.status_code) != status diff --git a/tests/compatibility_suite/test_v3_http_matching.py b/tests/compatibility_suite/test_v3_http_matching.py new file mode 100644 index 000000000..e01c2569e --- /dev/null +++ b/tests/compatibility_suite/test_v3_http_matching.py @@ -0,0 +1,200 @@ +"""Matching HTTP parts (request or response) feature tests.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +import pytest +from pytest_bdd import ( + given, + parsers, + scenario, + then, + when, +) + +from pact import Pact +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, +) +from tests.compatibility_suite.util.provider import Provider + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + from pact.verifier import Verifier + +################################################################################ +## Scenarios +################################################################################ + + +@scenario( + "definition/features/V3/http_matching.feature", + "Comparing accept headers where the actual has additional parameters", +) +def test_comparing_accept_headers_where_the_actual_has_additional_parameters() -> None: + """Comparing accept headers where the actual has additional parameters.""" + + +@scenario( + "definition/features/V3/http_matching.feature", + "Comparing accept headers where the actual has is missing a value", +) +def test_comparing_accept_headers_where_the_actual_has_is_missing_a_value() -> None: + """Comparing accept headers where the actual has is missing a value.""" + + +@scenario( + "definition/features/V3/http_matching.feature", + "Comparing content type headers where the actual has a charset", +) +def test_comparing_content_type_headers_where_the_actual_has_a_charset() -> None: + """Comparing content type headers where the actual has a charset.""" + + +@scenario( + "definition/features/V3/http_matching.feature", + "Comparing content type headers where the actual has a different charset", +) +def test_comparing_content_type_headers_where_the_actual_has_a_different_charset() -> ( + None +): + """Comparing content type headers where the actual has a different charset.""" + + +@scenario( + "definition/features/V3/http_matching.feature", + "Comparing content type headers where the actual is missing a charset", +) +def test_comparing_content_type_headers_where_the_actual_is_missing_a_charset() -> None: + """Comparing content type headers where the actual is missing a charset.""" + + +@scenario( + "definition/features/V3/http_matching.feature", + "Comparing content type headers where they have the same charset", +) +def test_comparing_content_type_headers_where_they_have_the_same_charset() -> None: + """Comparing content type headers where they have the same charset.""" + + +@scenario( + "definition/features/V3/http_matching.feature", + "Comparing content type headers which are equal", +) +def test_comparing_content_type_headers_which_are_equal() -> None: + """Comparing content type headers which are equal.""" + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.re( + r'a request is received with an? "(?P[^"]+)" header of "(?P[^"]+)"' + ), + target_fixture="interaction_definition", +) +def a_request_is_received_with_header(name: str, value: str) -> InteractionDefinition: + """A request is received with a "content-type" header of "application/json".""" + interaction_definition = InteractionDefinition(method="GET", path="/", type="HTTP") + interaction_definition.response_headers.update({name: value}) + return interaction_definition + + +@given( + parsers.re( + r'an expected request with an? "(?P[^"]+)" header of "(?P[^"]+)"' + ) +) +def an_expected_request_with_header(name: str, value: str, tmp_path: Path) -> None: + """An expected request with a "content-type" header of "application/json".""" + pact = Pact("consumer", "provider") + pact.with_specification("V3") + interaction_definition = InteractionDefinition(method="GET", path="/", type="HTTP") + interaction_definition.response_headers.update({name: value}) + interaction_definition.add_to_pact(pact, name) + pact.write_file(tmp_path) + + +################################################################################ +## When +################################################################################ + + +@when("the request is compared to the expected one", target_fixture="provider") +def the_request_is_compared_to_the_expected_one( + interaction_definition: InteractionDefinition, +) -> Generator[Provider, None, None]: + """The request is compared to the expected one.""" + provider = Provider() + provider.add_interaction(interaction_definition) + + with provider: + yield provider + + +################################################################################ +## Then +################################################################################ + + +@then( + parsers.re("the comparison should(?P( NOT)?) be OK"), + converters={"negated": lambda x: x == " NOT"}, + target_fixture="verifier_result", +) +def the_comparison_should_not_be_ok( + provider: Provider, + verifier: Verifier, + tmp_path: Path, + negated: bool, # noqa: FBT001 +) -> Verifier: + """The comparison should NOT be OK.""" + verifier.add_transport(url=provider.url) + verifier.add_transport( + protocol="http", + port=provider.url.port, + path="/", + ) + verifier.add_source(tmp_path) + if negated: + with pytest.raises(RuntimeError): + verifier.verify() + else: + verifier.verify() + return verifier + + +@then( + parsers.parse( + 'the mismatches will contain a mismatch with error "{mismatch_key}" ' + "-> \"Expected header '{header_name}' to have value '{expected_value}' " + "but was '{actual_value}'\"" + ) +) +def the_mismatches_will_contain_a_mismatch_with_error( + verifier_result: Verifier, + mismatch_key: str, + header_name: str, + expected_value: str, + actual_value: str, +) -> None: + """Mismatches will contain a mismatch with error.""" + expected_value_matcher = re.compile(expected_value) + actual_value_matcher = re.compile(actual_value) + expected_error_matcher = re.compile( + rf"Mismatch with header \'{mismatch_key}\': Expected header \'{header_name}\' " + rf"to have value \'{expected_value}\' but was \'{actual_value}\'" + ) + mismatch = verifier_result.results["errors"][0]["mismatch"]["mismatches"][0] + assert mismatch["key"] == mismatch_key + assert mismatch["type"] == "HeaderMismatch" + assert expected_value_matcher.match(mismatch["expected"]) is not None + assert actual_value_matcher.match(mismatch["actual"]) is not None + assert expected_error_matcher.match(mismatch["mismatch"]) is not None diff --git a/tests/compatibility_suite/test_v3_matching_rules.py b/tests/compatibility_suite/test_v3_matching_rules.py new file mode 100644 index 000000000..c8099c8ed --- /dev/null +++ b/tests/compatibility_suite/test_v3_matching_rules.py @@ -0,0 +1,437 @@ +"""V3 matching rules tests.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any +from urllib.parse import parse_qs + +import pytest +import requests +from pytest_bdd import ( + given, + parsers, + scenario, + then, + when, +) + +from pact.pact import Pact +from tests.compatibility_suite.util import ( + FIXTURES_ROOT, + parse_horizontal_table, +) +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from pact.error import Mismatch + +TEST_PACT_FILE_DIRECTORY = Path(Path(__file__).parent / "pacts") +EXT_TO_CONTENT_TYPE = { + "jpg": "image/jpeg", + "pdf": "application/pdf", + "json": "application/json", +} + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def pact() -> Pact: + return Pact( + "v3-matching-rules-consumer", + "v3-matching-rules-provider", + ).with_specification("V3") + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a Boolean matcher (negative case)", +) +def test_supports_a_boolean_matcher_negative_case() -> None: + """Supports a Boolean matcher (negative case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a Boolean matcher (positive case)", +) +def test_supports_a_boolean_matcher_positive_case() -> None: + """Supports a Boolean matcher (positive case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a ContentType matcher (negative case)", +) +def test_supports_a_contenttype_matcher_negative_case() -> None: + """Supports a ContentType matcher (negative case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a ContentType matcher (positive case)", +) +def test_supports_a_contenttype_matcher_positive_case() -> None: + """Supports a ContentType matcher (positive case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a Date and Time matcher (negative case)", +) +def test_supports_a_date_and_time_matcher_negative_case() -> None: + """Supports a Date and Time matcher (negative case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a Date and Time matcher (positive case)", +) +def test_supports_a_date_and_time_matcher_positive_case() -> None: + """Supports a Date and Time matcher (positive case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a Values matcher (negative case, final type is wrong)", +) +def test_supports_a_values_matcher_negative_case_final_type_is_wrong() -> None: + """Supports a Values matcher (negative case, final type is wrong).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a Values matcher (positive case, ignores missing and additional keys)", +) +def test_values_matcher_positive_case_missing_and_additional_keys() -> None: + """Supports a Values matcher (ignores missing and additional keys).""" + + +@pytest.mark.skip( + reason="Waiting on an upstream change in FFI and/or Compatibility Suite" +) +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a decimal type matcher " + "where it is acceptable to coerce values from string form", +) +def test_decimal_matcher_coerce_string_form() -> None: + """Supports a decimal type matcher string form.""" + + +@pytest.mark.skip( + reason="Waiting on an upstream change in FFI and/or Compatibility Suite" +) +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a decimal type matcher, " + "must have significant digits after the decimal point (negative case)", +) +def test_decimal_matcher_significant_digits_negative() -> None: + """Supports a decimal type matcher with decimal digits (negative case).""" + + +@pytest.mark.skip( + reason="Waiting on an upstream change in FFI and/or Compatibility Suite" +) +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a integer type matcher, " + "no digits after the decimal point (negative case)", +) +def test_integer_matcher_no_decimal_digits_negative() -> None: + """Tests integer matcher with no decimal digits (negative case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a minmax type matcher (negative case)", +) +def test_supports_a_minmax_type_matcher_negative_case() -> None: + """Supports a minmax type matcher (negative case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a minmax type matcher (positive case)", +) +def test_supports_a_minmax_type_matcher_positive_case() -> None: + """Supports a minmax type matcher (positive case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a null matcher (positive case)", +) +def test_supports_a_null_matcher_positive_case() -> None: + """Supports a null matcher (positive case).""" + + +@pytest.mark.skip( + reason="Waiting on an upstream change in FFI and/or Compatibility Suite" +) +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a number type matcher (negative case)", +) +def test_supports_a_number_type_matcher_negative_case() -> None: + """Supports a number type matcher (negative case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a number type matcher (positive case)", +) +def test_supports_a_number_type_matcher_positive_case() -> None: + """Supports a number type matcher (positive case).""" + + +@pytest.mark.skip( + reason="Waiting on an upstream change in FFI and/or Compatibility Suite" +) +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports a number type matcher " + "where it is acceptable to coerce values from string form", +) +def test_number_type_matcher_coerce_string_form() -> None: + """Tests number type matcher coerce string form.""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports an decimal type matcher, " + "must have significant digits after the decimal point (positive case)", +) +def test_decimal_matcher_significant_digits_positive() -> None: + """Tests decimal matcher with significant digits (positive case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports an equality matcher to reset cascading rules", +) +def test_supports_an_equality_matcher_to_reset_cascading_rules() -> None: + """Supports an equality matcher to reset cascading rules.""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports an include matcher (negative case)", +) +def test_supports_an_include_matcher_negative_case() -> None: + """Supports an include matcher (negative case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports an include matcher (positive case)", +) +def test_supports_an_include_matcher_positive_case() -> None: + """Supports an include matcher (positive case).""" + + +@pytest.mark.skip( + reason="Waiting on an upstream change in FFI and/or Compatibility Suite" +) +@scenario( + "definition/features/V3/matching_rules.feature", + ( + "Supports an integer type matcher " + "where it is acceptable to coerce values from string form" + ), +) +def test_integer_type_matcher_coerce_string_form() -> None: + """Supports an integer type matcher coerce string form.""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports an integer type matcher, " + "no digits after the decimal point (positive case)", +) +def test_integer_type_matcher_no_decimal_digits_positive() -> None: + """Tests integer type matcher no decimal digits (positive case).""" + + +@scenario( + "definition/features/V3/matching_rules.feature", + "Supports an null matcher (negative case)", +) +def test_supports_an_null_matcher_negative_case() -> None: + """Supports an null matcher (negative case).""" + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.re( + r"^(" + r"a request is received with the following:|" + r"the following requests are received:" + r")$" + ), + target_fixture="request_calls", +) +def a_request_is_received_with_the_following( + datatable: list[list[str]], +) -> list[Callable[[str], requests.Response]]: + """A request is received with the following:.""" + data = parse_horizontal_table(datatable) + assert len(data) > 0, "Expected at least one row in the table" + + body: Any + request_calls: list[Callable[[str], requests.Response]] = [] + for row in data: + content_type = row.pop("content type", None) + + if body := row.pop("body", None): + if body.startswith("JSON: "): + content_type = content_type or "application/json" + body = body.replace("JSON: ", "") + elif body.startswith("file: "): + content_type = ( + content_type or EXT_TO_CONTENT_TYPE[body.rsplit(".")[-1].lower()] + ) + body = (FIXTURES_ROOT / body.replace("file: ", "")).read_bytes() + + query: dict[str, list[str]] = ( + parse_qs(s) if (s := row.pop("query", None)) else {} + ) + headers = ( + dict(s.split(": ") for s in hs.strip("'").split("; ")) + if (hs := row.pop("headers", None)) + else {} + ) + + # Ignore description field + row.pop("desc", None) + + if row: + msg = f"Unexpected extra columns in table: {row!r}" + raise ValueError(msg) + + logger.debug( + "Configured POST request: %r", + { + "body": body, + "content_type": content_type, + "query": query, + "headers": headers, + }, + ) + + request_calls.append( + lambda url, # type: ignore[misc] + body=body, + content_type=content_type, + headers=headers, + query=query: requests.post( + url, + body, + timeout=2, + headers={ + **({"Content-Type": content_type} if content_type else {}), + **(headers), + }, + params=query, + ) + ) + + return request_calls + + +@given("an expected request configured with the following:") +def an_expected_request_configured_with( + pact: Pact, + datatable: list[list[str]], +) -> None: + """An expected request configured with.""" + data = parse_horizontal_table(datatable) + assert len(data) == 1, "Expected exactly one row in the table" + + interaction = InteractionDefinition( + method="POST", + path="/", + **data[0], # type: ignore[arg-type] + ) + interaction.add_to_pact(pact, "a matching rules request") + + +################################################################################ +## When +################################################################################ + + +@when( + parsers.re("the (request is|requests are) compared to the expected one"), + target_fixture="mismatches", +) +def the_request_is_compared_to_the_expected_one( + pact: Pact, + request_calls: list[Callable[[str], requests.Response]], +) -> list[Mismatch]: + """The request is compared to the expected one.""" + with pact.serve(raises=False) as srv: + for f in request_calls: + f(str(srv.url)) + + return srv.mismatches + + +################################################################################ +## Then +################################################################################ + + +@then( + parsers.re(r"the comparison should (?P(NOT )?)be OK"), + converters={"negated": lambda s: s == "NOT "}, +) +def the_comparison_should_be_ok( + negated: bool, # noqa: FBT001 + mismatches: list[Mismatch], +) -> None: + """The comparison should be OK.""" + if negated: + assert len(mismatches) > 0 + else: + assert len(mismatches) == 0 + + +@then( + parsers.re( + r"the mismatches will contain a mismatch " + r'with error "(?P[^"]+)" -> "(?P[^"]+)"' + ) +) +def the_mismatches_will_contain_a_mismatch_with_error( + path: str, + message: str, + mismatches: list[Mismatch], +) -> None: + """The mismatches will contain a mismatch with error.""" + logger.info("Searching for mismatch with path=%r, error=%r", path, message) + for mismatch in mismatches: + for submismatch in getattr(mismatch, "mismatches", []): + logger.info("Checking submismatch: %r", submismatch) + if ( + (s_path := getattr(submismatch, "path", None)) + and path == s_path + and (s_message := getattr(submismatch, "mismatch", None)) + and message in s_message + ): + logger.info("Found matching submismatch: %r", submismatch) + return + + msg = f"Mismatch not found: path={path!r}, error={message!r}" + raise AssertionError(msg) diff --git a/tests/compatibility_suite/test_v3_message_consumer.py b/tests/compatibility_suite/test_v3_message_consumer.py new file mode 100644 index 000000000..8d6f413f0 --- /dev/null +++ b/tests/compatibility_suite/test_v3_message_consumer.py @@ -0,0 +1,679 @@ +"""V3 Message consumer feature tests.""" + +from __future__ import annotations + +import ast +import json +import logging +import re +from typing import TYPE_CHECKING, Any, NamedTuple + +from pytest_bdd import ( + given, + parsers, + scenario, + then, + when, +) + +from tests.compatibility_suite.util import ( + FIXTURES_ROOT, + PactInteractionTuple, + parse_horizontal_table, +) +from tests.compatibility_suite.util.consumer import ( + a_message_integration_is_being_defined_for_a_consumer_test, +) + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + from pact.pact import AsyncMessageInteraction, InteractionVerificationError + +logger = logging.getLogger(__name__) + +################################################################################ +## Helpers +################################################################################ + + +class ReceivedMessage(NamedTuple): + """Holder class for Message Received Payload.""" + + body: Any + context: Any + + +class PactResult(NamedTuple): + """Holder class for Pact Result objects.""" + + messages: list[ReceivedMessage] + pact_data: dict[str, Any] | None + errors: list[InteractionVerificationError] + + +def assert_type(expected_type: str, value: Any) -> None: # noqa: ANN401 + logger.debug("Ensuring that %s is of type %s", value, expected_type) + if expected_type == "integer": + assert value is not None + assert isinstance(value, int) or re.match(r"^\d+$", value) + else: + msg = f"Unknown type: {expected_type}" + raise ValueError(msg) + + +################################################################################ +## Scenarios +################################################################################ + + +@scenario( + "definition/features/V3/message_consumer.feature", + "When all messages are successfully processed", +) +def test_when_all_messages_are_successfully_processed() -> None: + """When all messages are successfully processed.""" + + +@scenario( + "definition/features/V3/message_consumer.feature", + "When not all messages are successfully processed", +) +def test_when_not_all_messages_are_successfully_processed() -> None: + """When not all messages are successfully processed.""" + + +@scenario( + "definition/features/V3/message_consumer.feature", + "Supports arbitrary message metadata", +) +def test_supports_arbitrary_message_metadata() -> None: + """Supports arbitrary message metadata.""" + + +@scenario( + "definition/features/V3/message_consumer.feature", + "Supports specifying provider states", +) +def test_supports_specifying_provider_states() -> None: + """Supports specifying provider states.""" + + +@scenario( + "definition/features/V3/message_consumer.feature", + "Supports data for provider states", +) +def test_supports_data_for_provider_states() -> None: + """Supports data for provider states.""" + + +@scenario( + "definition/features/V3/message_consumer.feature", + "Supports the use of generators with the message body", +) +def test_supports_the_use_of_generators_with_the_message_body() -> None: + """Supports the use of generators with the message body.""" + + +@scenario( + "definition/features/V3/message_consumer.feature", + "Supports the use of generators with message metadata", +) +def test_supports_the_use_of_generators_with_message_metadata() -> None: + """Supports the use of generators with message metadata.""" + + +################################################################################ +## Given +################################################################################ + + +a_message_integration_is_being_defined_for_a_consumer_test("V3") + + +@given( + parsers.re( + r'a provider state "(?P[^"]+)" for the message is specified', + ), +) +def a_provider_state_for_the_message_is_specified( + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + state: str, +) -> None: + """A provider state for the message is specified.""" + logger.debug("Specifying provider state '%s'", state) + pact_interaction.interaction.given(state) + + +@given( + parsers.re( + r'a provider state "(?P[^"]+)" for the message is specified ' + r"with the following data:", + re.DOTALL, + ), +) +def a_provider_state_for_the_message_is_specified_with_the_following_data( + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + state: str, + datatable: list[list[str]], +) -> None: + """A provider state for the message is specified with the following data.""" + table = parse_horizontal_table(datatable) + logger.debug("Specifying provider state '%s' with data: %s", state, table) + parameters = {k: ast.literal_eval(v) for k, v in table[0].items()} + pact_interaction.interaction.given(state, parameters) + + +@given("a message is defined") +def a_message_is_defined() -> None: + """A message is defined.""" + + +@given( + parsers.re( + r"the message contains the following metadata:", + re.DOTALL, + ), +) +def the_message_contains_the_following_metadata( + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + datatable: list[list[str]], +) -> None: + """The message contains the following metadata.""" + metadatas = parse_horizontal_table(datatable) + logger.debug("Adding metadata to message: %s", metadatas) + for metadata in metadatas: + if metadata.get("value", "").startswith("JSON: "): + pact_interaction.interaction.with_metadata({ + metadata["key"]: json.loads(metadata["value"].replace("JSON: ", "")) + }) + continue + pact_interaction.interaction.with_metadata({metadata["key"]: metadata["value"]}) + + +@given( + parsers.re( + r"the message is configured with the following:", + re.DOTALL, + ), +) +def the_message_is_configured_with_the_following( + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + datatable: list[list[str]], +) -> None: + """The message is configured with the following.""" + table = parse_horizontal_table(datatable) + assert len(table) == 1, "Only one row is expected" + config: dict[str, str] = table[0] + + if body := config.pop("body", None): + if body.startswith("file: "): + file = FIXTURES_ROOT / body.replace("file: ", "") + content_type = "application/json" if file.suffix == ".json" else None + pact_interaction.interaction.with_body(file.read_text(), content_type) + else: + msg = f"Unsupported body configuration: {config['body']}" + raise ValueError(msg) + + if generators := config.pop("generators", None): + if generators.startswith("JSON: "): + data = json.loads(generators.replace("JSON: ", "")) + pact_interaction.interaction.with_generators(data) + else: + file = FIXTURES_ROOT / generators + pact_interaction.interaction.with_generators(file.read_text()) + + if metadata := config.pop("metadata", None): + data = json.loads(metadata) + pact_interaction.interaction.with_metadata({ + k: json.dumps(v) for k, v in data.items() + }) + + if config: + msg = f"Unknown configuration keys: {', '.join(config.keys())}" + raise ValueError(msg) + + +@given( + parsers.re(r'the message payload contains the "(?P[^"]+)" JSON document') +) +def the_message_payload_contains_the_basic_json_document( + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + basename: str, +) -> None: + """The message payload contains the "basic" JSON document.""" + json_path = FIXTURES_ROOT / f"{basename}.json" + if not json_path.is_file(): + msg = f"File not found: {json_path}" + raise FileNotFoundError(msg) + pact_interaction.interaction.with_body( + json_path.read_text(), + content_type="application/json", + ) + + +################################################################################ +## When +################################################################################ + + +@when("the message is successfully processed", target_fixture="pact_result") +def the_message_is_successfully_processed( + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + tmp_path: Path, +) -> Generator[PactResult, None, None]: + """The message is successfully processed.""" + messages: list[ReceivedMessage] = [] + + def handler( + body: str | bytes | None, + context: dict[str, object], + ) -> None: + messages.append(ReceivedMessage(body, context)) + + # While the expectation is that the message will be processed successfully, + # we don't raise an exception and instead capture any errors that occur. + errors = pact_interaction.pact.verify(handler, "Async", raises=False) + if errors: + logger.error("%d errors occurred during verification:", len(errors)) + for error in errors: + logger.error(error) + msg = "Errors occurred during verification" + raise AssertionError(msg) + + pact_interaction.pact.write_file(tmp_path) + with (tmp_path / "consumer-provider.json").open() as file: + pact_data = json.load(file) + + yield PactResult(messages, pact_data, errors) + + +@when( + parsers.re( + r"the message is NOT successfully processed " + r'with a "(?P[^"]+)" exception' + ), + target_fixture="pact_result", +) +def the_message_is_not_successfully_processed_with_an_exception( + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + failure: str, +) -> PactResult: + """The message is NOT successfully processed with a "Test failed" exception.""" + messages: list[ReceivedMessage] = [] + + def handler(body: str | bytes | None, context: dict[str, object]) -> None: + messages.append(ReceivedMessage(body, context)) + raise AssertionError(failure) + + errors = pact_interaction.pact.verify(handler, "Async", raises=False) + return PactResult(messages, None, errors) + + +################################################################################ +## Then +################################################################################ + + +@then( + parsers.re( + r"a Pact file for the message interaction " + r"will(?P( NOT)?) have been written" + ), + converters={"success": lambda x: x != " NOT"}, +) +def a_pact_file_for_the_message_interaction_will_maybe_have_been_written( + tmp_path: Path, + success: bool, # noqa: FBT001 +) -> None: + """A Pact file for the message interaction will maybe have been written.""" + assert (tmp_path / "consumer-provider.json").exists() == success + + +@then(parsers.re(r'the consumer test error will be "(?P[^"]+)"')) +def the_consumer_test_error_will_be_test_failed( + pact_result: PactResult, + error: str, +) -> None: + """The consumer test error will be "Test failed".""" + assert len(pact_result.errors) == 1 + assert error in str(pact_result.errors[0].error) + + +@then( + parsers.re(r"the consumer test will have (?Ppassed|failed)"), + converters={"success": lambda x: x == "passed"}, +) +def the_consumer_test_will_have_passed_or_failed( + pact_result: PactResult, + success: bool, # noqa: FBT001 +) -> None: + """The consumer test will have passed or failed.""" + assert (len(pact_result.errors) == 0) == success + + +@then( + parsers.re( + r"the first message in the pact file content type " + r'will be "(?P[^"]+)"' + ) +) +def the_first_message_in_the_pact_file_content_type_will_be( + pact_result: PactResult, + content_type: str, +) -> None: + """The first message in the pact file content type will be.""" + if not pact_result.pact_data: + msg = "No pact data found" + raise RuntimeError(msg) + messages: list[dict[str, dict[str, Any]]] = pact_result.pact_data["messages"] + if not isinstance(messages, list) or not messages: + msg = "No messages found" + raise RuntimeError(msg) + assert messages[0].get("metadata", {}).get("contentType") == content_type + + +@then( + parsers.re( + r"the first message in the pact file will contain " + r"(?P\d+) provider states?" + ), + converters={"state_count": int}, +) +def the_first_message_in_the_pact_file_will_contain( + pact_result: PactResult, + state_count: int, +) -> None: + """The first message in the pact file will contain 1 provider state.""" + if not pact_result.pact_data: + msg = "No pact data found" + raise RuntimeError(msg) + messages: list[dict[str, list[Any]]] = pact_result.pact_data["messages"] + if not isinstance(messages, list) or not messages: + msg = "No messages found" + raise RuntimeError(msg) + assert len(messages[0].get("providerStates", [])) == state_count + + +@then( + parsers.re( + r"the first message in the Pact file will contain " + r'provider state "(?P[^"]+)"' + ) +) +def the_first_message_in_the_pact_file_will_contain_provider_state( + pact_result: PactResult, + state: str, +) -> None: + """The first message in the Pact file will contain provider state.""" + if not pact_result.pact_data: + msg = "No pact data found" + raise RuntimeError(msg) + messages = pact_result.pact_data["messages"] + if not isinstance(messages, list) or not messages: + msg = "No messages found" + raise RuntimeError(msg) + message: dict[str, Any] = messages[0] + provider_states: list[dict[str, Any]] = message.get("providerStates", []) + for provider_state in provider_states: + if provider_state["name"] == state: + break + else: + msg = f"Provider state not found: {state}" + raise AssertionError(msg) + + +@then( + parsers.re( + r"the first message in the pact file will contain " + r'the "(?P[^"]+)" document' + ) +) +def the_first_message_in_the_pact_file_will_contain_the_basic_json_document( + pact_result: PactResult, + basename: str, +) -> None: + """The first message in the pact file will contain the "basic.json" document.""" + path = FIXTURES_ROOT / basename + if not path.is_file(): + msg = f"File not found: {path}" + raise FileNotFoundError(msg) + if not pact_result.pact_data: + msg = "No pact data found" + raise RuntimeError(msg) + messages: list[dict[str, Any]] = pact_result.pact_data["messages"] + if not isinstance(messages, list) or not messages: + msg = "No messages found" + raise RuntimeError(msg) + try: + assert messages[0]["contents"] == json.loads(path.read_text()) + except json.JSONDecodeError as e: + logger.info("Error decoding JSON: %s", e) + logger.info("Performing basic string comparison") + assert messages[0]["contents"] == path.read_text() + + +@then( + parsers.re( + r"the first message in the pact file will contain " + r'the message metadata "(?P[^"]+)" == "(?P[^"\\]*(?:\\.[^"\\]*)*)"' + ) +) +def the_first_message_in_the_pact_file_will_contain_the_message_metadata( + pact_result: PactResult, + key: str, + value: Any, # noqa: ANN401 +) -> None: + """The first message in the pact file will contain the message metadata.""" + if value.startswith("JSON: "): + value = value.replace("JSON: ", "") + value = value.replace('\\"', '"') + value = json.loads(value) + if not pact_result.pact_data: + msg = "No pact data found" + raise RuntimeError(msg) + messages: list[dict[str, dict[str, Any]]] = pact_result.pact_data["messages"] + assert messages[0]["metadata"][key] == value + + +@then( + parsers.re( + r'the message contents for "(?P[^"]+)" ' + r'will have been replaced with an? "(?P[^"]+)"' + ) +) +def the_message_contents_will_have_been_replaced_with( + pact_result: PactResult, + path: str, + expected_type: str, +) -> None: + """The message contents for "$.one" will have been replaced with an "integer".""" + json_path = path.split(".") + assert len(json_path) == 2, "Only one level of nesting is supported" + assert json_path[0] == "$", "Only root level replacement is supported" + key = json_path[1] + + assert len(pact_result.messages) == 1 + message = pact_result.messages[0] + value = json.loads(message.body).get(key) + assert_type(expected_type, value) + + +@then( + parsers.parse( + "the pact file will contain {interaction_count:d} message interaction" + ) +) +def the_pact_file_will_contain_message_interaction( + pact_result: PactResult, + interaction_count: int, +) -> None: + """The pact file will contain N message interaction.""" + if not pact_result.pact_data: + msg = "No pact data found" + raise RuntimeError(msg) + messages: list[Any] = pact_result.pact_data["messages"] + assert len(messages) == interaction_count + + +@then( + parsers.re( + r'the provider state "(?P[^"]+)" for the message ' + r"will contain the following parameters:", + re.DOTALL, + ), +) +def the_provider_state_for_the_message_will_contain_the_following_parameters( + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + pact_result: PactResult, + state: str, + datatable: list[list[str]], +) -> None: + """The provider state for the message will contain the following parameters.""" + table = parse_horizontal_table(datatable) + assert len(table) == 1, "Only one row is expected" + expected = json.loads(table[0]["parameters"]) + logger.debug("Checking provider state '%s' parameters: %s", state, expected) + + # It is unclear whether this test is meant to verify the `Interaction` + # object, or the result as written to the Pact file. As a result, we + # will perform both checks. + + ## Verifying the Pact File + + if not pact_result.pact_data: + msg = "No pact data found" + raise RuntimeError(msg) + messages: list[dict[str, list[dict[str, Any]]]] = pact_result.pact_data["messages"] + assert len(messages) == 1, "Only one message is expected" + message = messages[0] + + assert len(message["providerStates"]) > 0, "At least one provider state is expected" + provider_states = message["providerStates"] + logger.debug("Provider states: %s", provider_states) + for provider_state_dict in provider_states: + if provider_state_dict["name"] == state: + assert expected == provider_state_dict["params"] + break + else: + msg = f"Provider state not found in Pact file: {state}" + raise AssertionError(msg) + + ## Verifying the Interaction Object + + for interaction in pact_interaction.pact.interactions("Async"): + for provider_state in interaction.provider_states(): + if provider_state.name == state: + provider_state_params = { + k: ast.literal_eval(v) for k, v in provider_state.parameters() + } + assert expected == provider_state_params + break + else: + msg = f"Provider state not found: {state}" + raise ValueError(msg) + break + else: + msg = "No interactions found" + raise ValueError(msg) + + +@then( + parsers.re(r'the received message content type will be "(?P[^"]+)"') +) +def the_received_message_content_type_will_be( + pact_result: PactResult, + content_type: str, +) -> None: + """The received message content type will be "application/json".""" + assert len(pact_result.messages) == 1 + message = pact_result.messages[0] + assert message.context.get("contentType") == content_type + + +@then( + parsers.re( + r"the received message metadata will contain " + r'"(?P[^"]+)" == "(?P[^"\\]*(?:\\.[^"\\]*)*)"' + ) +) +def the_received_message_metadata_will_contain( + pact_result: PactResult, + key: str, + value: Any, # noqa: ANN401 +) -> None: + """The received message metadata will contain.""" + # If we're given some JSON value, we will need to parse the value from the + # `message.context` and compare it to the parsed JSON value; otherwise, + # equivalent JSON values may not match due to formatting differences. + json_matching = False + if value.startswith("JSON: "): + value = value.replace("JSON: ", "").replace(r"\"", '"') + value = json.loads(value) + json_matching = True + + assert len(pact_result.messages) == 1 + message = pact_result.messages[0] + for k, v in message.context.items(): + if k == key: + if json_matching: + assert v == value + else: + assert v == value + break + else: + msg = f"Key '{key}' not found in message metadata" + raise AssertionError(msg) + + +@then( + parsers.re( + r'the received message metadata will contain "(?P[^"]+)" ' + r'replaced with an? "(?P[^"]+)"' + ) +) +def the_received_message_metadata_will_contain_replaced_with( + pact_result: PactResult, + key: str, + expected_type: str, +) -> None: + """The received message metadata will contain "ID" replaced with an "integer".""" + assert isinstance(pact_result.messages, list) + assert len(pact_result.messages) == 1, "Only one message is expected" + message = pact_result.messages[0] + value = message.context.get(key) + assert_type(expected_type, value) + + +@then( + parsers.re( + r"the received message payload will contain " + r'the "(?P[^"]+)" JSON document' + ) +) +def the_received_message_payload_will_contain_the_basic_json_document( + pact_result: PactResult, + basename: str, +) -> None: + """The received message payload will contain the JSON document.""" + json_path = FIXTURES_ROOT / f"{basename}.json" + if not json_path.is_file(): + msg = f"File not found: {json_path}" + raise FileNotFoundError(msg) + + assert len(pact_result.messages) == 1 + message = pact_result.messages[0] + + try: + assert json.loads(message.body) == json.loads(json_path.read_text()) + except json.JSONDecodeError as e: + logger.info("Error decoding JSON: %s", e) + logger.info("Performing basic comparison") + if isinstance(message.body, str): + assert message.body == json_path.read_text() + elif isinstance(message.body, bytes): + assert message.body == json_path.read_bytes() + else: + msg = f"Unexpected message body type: {type(message.body).__name__}" + raise TypeError(msg) from None diff --git a/tests/compatibility_suite/test_v3_message_producer.py b/tests/compatibility_suite/test_v3_message_producer.py new file mode 100644 index 000000000..fe8ec008e --- /dev/null +++ b/tests/compatibility_suite/test_v3_message_producer.py @@ -0,0 +1,352 @@ +"""V3 Message provider feature tests.""" + +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest +from pytest_bdd import ( + given, + parsers, + scenario, +) + +from pact.pact import Pact +from tests.compatibility_suite.util import ( + parse_horizontal_table, + parse_vertical_table, +) +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, + InteractionState, +) +from tests.compatibility_suite.util.provider import ( + a_pact_file_for_message_is_to_be_verified, + a_provider_is_started_that_can_generate_the_message, + a_provider_state_callback_is_configured, + the_provider_state_callback_will_be_called_after_the_verification_is_run, + the_provider_state_callback_will_be_called_before_the_verification_is_run, + the_provider_state_callback_will_receive_a_setup_call, + the_verification_is_run, + the_verification_results_will_contain_a_error, + the_verification_will_be_successful, +) + +if TYPE_CHECKING: + from pact.verifier import Verifier + +TEST_PACT_FILE_DIRECTORY = Path(Path(__file__).parent / "pacts") + +logger = logging.getLogger(__name__) + + +@scenario( + "definition/features/V3/message_provider.feature", + "Incorrect message is generated by the provider", +) +def test_incorrect_message_is_generated_by_the_provider() -> None: + """Incorrect message is generated by the provider.""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Message with JSON body (negative case)", +) +def test_message_with_json_body_negative_case() -> None: + """Message with JSON body (negative case).""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Message with JSON body (positive case)", +) +def test_message_with_json_body_positive_case() -> None: + """Message with JSON body (positive case).""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Message with XML body (negative case)", +) +def test_message_with_xml_body_negative_case() -> None: + """Message with XML body (negative case).""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Message with XML body (positive case)", +) +def test_message_with_xml_body_positive_case() -> None: + """Message with XML body (positive case).""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Message with binary body (negative case)", +) +def test_message_with_binary_body_negative_case() -> None: + """Message with binary body (negative case).""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Message with binary body (positive case)", +) +def test_message_with_binary_body_positive_case() -> None: + """Message with binary body (positive case).""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Message with plain text body (negative case)", +) +def test_message_with_plain_text_body_negative_case() -> None: + """Message with plain text body (negative case).""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Message with plain text body (positive case)", +) +def test_message_with_plain_text_body_positive_case() -> None: + """Message with plain text body (positive case).""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Supports matching rules for the message body (negative case)", +) +def test_supports_matching_rules_for_the_message_body_negative_case() -> None: + """Supports matching rules for the message body (negative case).""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Supports matching rules for the message body (positive case)", +) +def test_supports_matching_rules_for_the_message_body_positive_case() -> None: + """Supports matching rules for the message body (positive case).""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Supports matching rules for the message metadata (negative case)", +) +def test_supports_matching_rules_for_the_message_metadata_negative_case() -> None: + """Supports matching rules for the message metadata (negative case).""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Supports matching rules for the message metadata (positive case)", +) +def test_supports_matching_rules_for_the_message_metadata_positive_case() -> None: + """Supports matching rules for the message metadata (positive case).""" + + +@pytest.mark.skip("Currently unable to implement") +@scenario( + "definition/features/V3/message_provider.feature", + "Supports messages with body formatted for the Kafka schema registry", +) +def test_supports_messages_with_body_formatted_for_the_kafka_schema_registry() -> None: + """Supports messages with body formatted for the Kafka schema registry.""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Verifies the message metadata", +) +def test_verifies_the_message_metadata() -> None: + """Verifies the message metadata.""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Verifying a simple message", +) +def test_verifying_a_simple_message() -> None: + """Verifying a simple message.""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Verifying an interaction with a defined provider state", +) +def test_verifying_an_interaction_with_a_defined_provider_state() -> None: + """Verifying an interaction with a defined provider state.""" + + +@scenario( + "definition/features/V3/message_provider.feature", + "Verifying multiple Pact files", +) +def test_verifying_multiple_pact_files() -> None: + """Verifying multiple Pact files.""" + + +################################################################################ +## Given +################################################################################ + + +a_provider_is_started_that_can_generate_the_message() +a_provider_state_callback_is_configured() +a_pact_file_for_message_is_to_be_verified("V3") + + +@given( + parsers.re( + r'a Pact file for "(?P[^"]+)" is to be verified with the following:', + re.DOTALL, + ), +) +def a_pact_file_for_is_to_be_verified_with_the_following( + verifier: Verifier, + tmp_path: Path, + name: str, + datatable: list[list[str]], +) -> None: + """ + A Pact file for "basic" is to be verified with the following. + """ + pact = Pact("consumer", "provider") + pact.with_specification("V3") + + table: dict[str, Any] = parse_vertical_table(datatable) + if "metadata" in table: + assert isinstance(table["metadata"], str) + metadata = { + k: json.loads(v.replace("JSON: ", "")) if v.startswith("JSON: ") else v + for k, _, v in (s.partition("=") for s in table["metadata"].split("; ")) + } + table["metadata"] = metadata + + interaction_definition = InteractionDefinition( + type="Async", + description=name, + **table, # type: ignore[arg-type] + ) + interaction_definition.add_to_pact(pact, name) + pact.write_file(tmp_path) + verifier.add_source(tmp_path) + + +@given( + parsers.re( + r'a Pact file for "(?P[^"]+)":"(?P[^"]+)"' + r' is to be verified with provider state "(?P[^"]+)"' + ) +) +def a_pact_file_for_is_to_be_verified_with_provider_state( + tmp_path: Path, + verifier: Verifier, + name: str, + fixture: str, + provider_state: str, +) -> None: + """A Pact file is to be verified with provider state.""" + pact = Pact("consumer", "provider") + pact.with_specification("V3") + interaction_definition = InteractionDefinition( + type="Async", + description=name, + body=fixture, + ) + interaction_definition.states = [InteractionState(provider_state)] + interaction_definition.add_to_pact(pact, name) + pact.write_file(tmp_path) + verifier.add_source(tmp_path) + with (tmp_path / "provider_states").open("w") as f: + logger.debug("Writing provider state to %s", tmp_path / "provider_states") + json.dump([s.as_dict() for s in [InteractionState(provider_state)]], f) + + +@given( + parsers.re( + r'a Pact file for "(?P[^"]+)":"(?P[^"]+)" is ' + "to be verified with the following metadata:", + re.DOTALL, + ), +) +def a_pact_file_for_is_to_be_verified_with_the_following_metadata( + tmp_path: Path, + verifier: Verifier, + name: str, + fixture: str, + datatable: list[list[str]], +) -> None: + """A Pact file is to be verified with the following metadata.""" + metadata = parse_horizontal_table(datatable) + pact = Pact("consumer", "provider") + pact.with_specification("V3") + interaction_definition = InteractionDefinition( + type="Async", + description=name, + body=fixture, + metadata={ + row["key"]: json.loads(row["value"].replace("JSON: ", "")) + if row["value"].startswith("JSON: ") + else row["value"] + for row in metadata + }, + ) + interaction_definition.add_to_pact(pact, name) + pact.write_file(tmp_path) + verifier.add_source(tmp_path) + + +@given( + parsers.re( + r'a provider is started that can generate the "(?P[^"]+)" ' + r'message with "(?P[^"]+)" and the following metadata:', + re.DOTALL, + ), + target_fixture="provider", +) +def a_provider_is_started_that_can_generate_the_message_with_the_following_metadata( + verifier: Verifier, + name: str, + fixture: str, + datatable: list[list[str]], +) -> None: + """A provider is started that can generate the message with the following metadata.""" # noqa: E501 + metadata = parse_horizontal_table(datatable) + + interaction = InteractionDefinition( + type="Async", + description=name, + body=fixture, + metadata={ + row["key"]: json.loads(row["value"].replace("JSON: ", "")) + if row["value"].startswith("JSON: ") + else row["value"] + for row in metadata + }, + ) + + verifier.message_handler(interaction.message_producer) + + +################################################################################ +## When +################################################################################ + + +the_verification_is_run() + + +################################################################################ +## Then +################################################################################ + + +the_provider_state_callback_will_be_called_before_the_verification_is_run() +the_provider_state_callback_will_be_called_after_the_verification_is_run() +the_provider_state_callback_will_receive_a_setup_call() +the_verification_will_be_successful() +the_verification_results_will_contain_a_error() diff --git a/tests/compatibility_suite/test_v3_provider.py b/tests/compatibility_suite/test_v3_provider.py new file mode 100644 index 000000000..5470bd531 --- /dev/null +++ b/tests/compatibility_suite/test_v3_provider.py @@ -0,0 +1,117 @@ +""" +Basic HTTP provider feature test. +""" + +from __future__ import annotations + +import logging + +from pytest_bdd import given, parsers, scenario + +from tests.compatibility_suite.util import parse_horizontal_table +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, +) +from tests.compatibility_suite.util.provider import ( + a_pact_file_for_interaction_is_to_be_verified_with_a_provider_states_defined, + a_provider_is_started_that_returns_the_responses_from_interactions, + a_provider_state_callback_is_configured, + the_provider_state_callback_will_be_called_after_the_verification_is_run, + the_provider_state_callback_will_be_called_before_the_verification_is_run, + the_provider_state_callback_will_receive_a_setup_call, + the_provider_state_callback_will_receive_a_setup_call_with_parameters, + the_verification_is_run, +) + +logger = logging.getLogger(__name__) + + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V3/http_provider.feature", + "Verifying an interaction with multiple defined provider states", +) +def test_verifying_an_interaction_with_multiple_defined_provider_states() -> None: + """ + Verifying an interaction with multiple defined provider states. + """ + + +@scenario( + "definition/features/V3/http_provider.feature", + "Verifying an interaction with a provider state with parameters", +) +def test_verifying_an_interaction_with_a_provider_state_with_parameters() -> None: + """ + Verifying an interaction with a provider state with parameters. + """ + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.parse("the following HTTP interactions have been defined:"), + target_fixture="interaction_definitions", +) +def the_following_http_interactions_have_been_defined( + datatable: list[list[str]], +) -> dict[int, InteractionDefinition]: + """ + Parse the HTTP interactions table into a dictionary. + + The table columns are expected to be: + + - No + - method + - path + - response + - response headers + - response content + - response body + - response matching rules + + The first row is ignored, as it is assumed to be the column headers. The + order of the columns is similarly ignored. + """ + logger.debug("Parsing interaction definitions") + + # Check that the table is well-formed + definitions = parse_horizontal_table(datatable) + assert len(definitions[0]) == 8, f"Expected 8 columns, got {len(definitions[0])}" + assert "No" in definitions[0], "'No' column not found" + + # Parse the table into a more useful format + interactions: dict[int, InteractionDefinition] = {} + for row in definitions: + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] + return interactions + + +a_pact_file_for_interaction_is_to_be_verified_with_a_provider_states_defined("V3") +a_provider_is_started_that_returns_the_responses_from_interactions() +a_provider_state_callback_is_configured() + +################################################################################ +## When +################################################################################ + + +the_verification_is_run() + + +################################################################################ +## Then +################################################################################ + + +the_provider_state_callback_will_be_called_after_the_verification_is_run() +the_provider_state_callback_will_be_called_before_the_verification_is_run() +the_provider_state_callback_will_receive_a_setup_call() +the_provider_state_callback_will_receive_a_setup_call_with_parameters() diff --git a/tests/compatibility_suite/test_v4_consumer.py b/tests/compatibility_suite/test_v4_consumer.py new file mode 100644 index 000000000..ddfa665ef --- /dev/null +++ b/tests/compatibility_suite/test_v4_consumer.py @@ -0,0 +1,157 @@ +"""Basic HTTP consumer feature tests.""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any + +from pytest_bdd import given, parsers, scenario, then + +from pact.pact import HttpInteraction, Pact +from tests.compatibility_suite.util import PactInteractionTuple, string_to_int +from tests.compatibility_suite.util.consumer import ( + the_pact_file_for_the_test_is_generated, +) + +if TYPE_CHECKING: + from collections.abc import Generator + +logger = logging.getLogger(__name__) + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V4/http_consumer.feature", + "Sets the type for the interaction", +) +def test_sets_the_type_for_the_interaction() -> None: + """Sets the type for the interaction.""" + + +@scenario( + "definition/features/V4/http_consumer.feature", + "Supports specifying a key for the interaction", +) +def test_supports_specifying_a_key_for_the_interaction() -> None: + """Supports specifying a key for the interaction.""" + + +@scenario( + "definition/features/V4/http_consumer.feature", + "Supports specifying the interaction is pending", +) +def test_supports_specifying_the_interaction_is_pending() -> None: + """Supports specifying the interaction is pending.""" + + +@scenario( + "definition/features/V4/http_consumer.feature", + "Supports adding comments", +) +def test_supports_adding_comments() -> None: + """Supports adding comments.""" + + +################################################################################ +## Given +################################################################################ + + +@given( + "an HTTP interaction is being defined for a consumer test", + target_fixture="pact_interaction", +) +def an_http_interaction_is_being_defined_for_a_consumer_test() -> Generator[ + PactInteractionTuple[HttpInteraction], Any, None +]: + """An HTTP interaction is being defined for a consumer test.""" + pact = Pact("consumer", "provider") + pact.with_specification("V4") + yield PactInteractionTuple(pact, pact.upon_receiving("a request")) + + +@given(parsers.re(r'a key of "(?P[^"]+)" is specified for the HTTP interaction')) +def a_key_is_specified_for_the_http_interaction( + pact_interaction: PactInteractionTuple[HttpInteraction], + key: str, +) -> None: + """A key is specified for the HTTP interaction.""" + pact_interaction.interaction.set_key(key) + + +@given("the HTTP interaction is marked as pending") +def the_http_interaction_is_marked_as_pending( + pact_interaction: PactInteractionTuple[HttpInteraction], +) -> None: + """The HTTP interaction is marked as pending.""" + pact_interaction.interaction.set_pending(pending=True) + + +@given(parsers.re(r'a comment "(?P[^"]+)" is added to the HTTP interaction')) +def a_comment_is_added_to_the_http_interaction( + pact_interaction: PactInteractionTuple[HttpInteraction], + comment: str, +) -> None: + """A comment of "" is added to the HTTP interaction.""" + pact_interaction.interaction.set_comment("text", [comment]) + + +################################################################################ +## When +################################################################################ + + +the_pact_file_for_the_test_is_generated() + + +################################################################################ +## Then +################################################################################ + + +@then( + parsers.re( + r"the (?P[^ ]+) interaction in the Pact file" + r' will have a type of "(?P[^"]+)"' + ), + converters={"num": string_to_int}, +) +def the_interaction_in_the_pact_file_will_container_provider_states( + pact_data: dict[str, Any], + num: int, + interaction_type: str, +) -> None: + """The interaction in the Pact file will container provider states.""" + assert "interactions" in pact_data + assert len(pact_data["interactions"]) >= num + interaction = pact_data["interactions"][num - 1] + assert interaction["type"] == interaction_type + + +@then( + parsers.re( + r"the (?P[^ ]+) interaction in the Pact file" + r" will have \"(?P[^\"]+)\" = '(?P[^']+)'" + ), + converters={"num": string_to_int}, +) +def the_interaction_in_the_pact_file_will_have_a_key_of( + pact_data: dict[str, Any], + num: int, + key: str, + value: str, +) -> None: + """The interaction in the Pact file will have a key of value.""" + assert "interactions" in pact_data + assert len(pact_data["interactions"]) >= num + interaction = pact_data["interactions"][num - 1] + assert key in interaction + value = json.loads(value) + if isinstance(value, list): + assert interaction[key] in value + else: + assert interaction[key] == value diff --git a/tests/compatibility_suite/test_v4_generators.py b/tests/compatibility_suite/test_v4_generators.py new file mode 100644 index 000000000..d0dcaf2cd --- /dev/null +++ b/tests/compatibility_suite/test_v4_generators.py @@ -0,0 +1,324 @@ +"""Test of V4 generators.""" + +from __future__ import annotations + +import json +import logging +import re +from contextvars import ContextVar +from typing import TYPE_CHECKING, Literal + +import pytest +import requests +from pytest_bdd import ( + given, + parsers, + scenario, + then, + when, +) + +from pact import Pact, Verifier +from tests.compatibility_suite.util import parse_horizontal_table +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, + InteractionState, +) +from tests.compatibility_suite.util.provider import Provider + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + +logger = logging.getLogger(__name__) + +SERVER_URL: ContextVar[str | None] = ContextVar("SERVER_URL", default=None) + + +@pytest.fixture +def pact() -> Pact: + return Pact( + "v4-generators-consumer", + "v4-generators-provider", + ).with_specification("V4") + + +@pytest.fixture +def verifier() -> Verifier: + return Verifier("v4-generators-provider") + + +def test_provider_state_generator( + pact: Pact, tmp_path: Path, verifier: Verifier +) -> None: + """Test the provider state generator.""" + ( + pact + .upon_receiving("a generators request") + .given("a provider state exists", {"id": 1000}) + .with_request("POST", "/") + .with_body({"one": "a", "two": "b"}) + .with_generators({ + "body": { + "$.one": { + "type": "ProviderState", + "expression": "${id}", + } + } + }) + .will_respond_with(200) + ) + + pacts_path = tmp_path / "pacts" + pacts_path.mkdir(exist_ok=True, parents=True) + pact.write_file(pacts_path) + verifier.add_source(pacts_path) + + provider = Provider() + provider.add_interaction( + InteractionDefinition( + method="POST", + path="/", + response="200", + ) + ) + verifier.add_transport(url=provider.url) + + with provider: + verifier.verify() + + assert provider.requests + assert len(provider.requests) == 1 + request = provider.requests[0] + assert request["body"] + assert json.loads(request["body"]) == {"one": 1000, "two": "b"} + + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V4/generators.feature", + "Supports a Mock server URL generator", +) +def test_supports_a_mock_server_url_generator() -> None: + """Supports a Mock server URL generator.""" + + +@pytest.mark.skip(reason="Manually implemented outside of pytest-bdd") +@scenario( + "definition/features/V4/generators.feature", + "Supports a Provider State generator", +) +def test_supports_a_provider_state_generator() -> None: + """Supports a Provider State generator.""" + + +@scenario( + "definition/features/V4/generators.feature", + "Supports a URN UUID generator", +) +def test_supports_a_urn_uuid_generator() -> None: + """Supports a URN UUID generator.""" + + +@scenario( + "definition/features/V4/generators.feature", + "Supports a lower-case-hyphenated UUID generator", +) +def test_supports_a_lowercasehyphenated_uuid_generator() -> None: + """Supports a lower-case-hyphenated UUID generator.""" + + +@scenario( + "definition/features/V4/generators.feature", + "Supports a simple UUID generator", +) +def test_supports_a_simple_uuid_generator() -> None: + """Supports a simple UUID generator.""" + + +@scenario( + "definition/features/V4/generators.feature", + "Supports a upper-case-hyphenated UUID generator", +) +def test_supports_a_uppercasehyphenated_uuid_generator() -> None: + """Supports a upper-case-hyphenated UUID generator.""" + + +################################################################################ +## Scenario +################################################################################ + + +@given( + "a request configured with the following generators:", + target_fixture="interaction", +) +def a_request_configured_with_the_following_generators( + pact: Pact, + datatable: list[list[str]], +) -> InteractionDefinition: + """A request configured with the following generators.""" + data = parse_horizontal_table(datatable) + assert len(data) == 1, "Expected a single row of data" + row = data[0] + + # These tests only define the response + row["response body"] = row.pop("body") + row["response generators"] = row.pop("generators") + + interaction = InteractionDefinition( + method="GET", + path="/", + response="200", + **row, # type: ignore[arg-type] + ) + interaction.states.append(InteractionState("a provider state exists", {"id": 1000})) + interaction.add_to_pact(pact, "a generators request") + return interaction + + +@given( + parsers.re(r'the generator test mode is set as "(?PConsumer|Provider)"'), + target_fixture="mode", +) +def the_generator_test_mode_is_set( + mode: Literal["Consumer", "Provider"], +) -> Literal["Consumer", "Provider"]: + """The generator test mode is set.""" + return mode + + +################################################################################ +## When +################################################################################ + + +@when("the request is prepared for use", target_fixture="response") +def the_request_is_prepared_for_use(pact: Pact) -> requests.Response: + """The request is prepared for use.""" + with pact.serve() as srv: + response = requests.get( + str(srv.url), + timeout=2, + ) + response.raise_for_status() + + return response + + +@when( + parsers.re( + r"the request is prepared for use " + r'with a "(?PmockServer)" context:' + ), + target_fixture="response", +) +def the_request_is_prepared_with_context( + pact: Pact, + context: Literal["mockServer"], + datatable: list[list[str]], +) -> requests.Response | Provider: + """The request is prepared for use with a context:.""" + if context == "mockServer": + data = json.loads(datatable[0][0]) + assert data + with pact.serve() as srv: + SERVER_URL.set(str(srv.url)) + response = requests.get( + str(srv.url), + timeout=2, + ) + response.raise_for_status() + + return response + + msg = f"Unknown context {context!r}" + raise ValueError(msg) + + +################################################################################ +## Then +################################################################################ + + +GENERATOR_PATTERN: dict[str, Callable[[object], bool]] = { + "upper-case-hyphenated UUID": lambda v: ( + isinstance(v, str) + and re.match( + r"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", v + ) + is not None + ), + "lower-case-hyphenated UUID": lambda v: ( + isinstance(v, str) + and re.match( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", v + ) + is not None + ), + "simple UUID": lambda v: ( + isinstance(v, str) and re.match(r"^[0-9a-fA-F]{32}$", v) is not None + ), + "URN UUID": lambda v: ( + isinstance(v, str) + and re.match( + r"^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + v, + ) + is not None + ), +} + + +@then( + parsers.re( + r'the body value for "\$\.one" ' + r'will have been replaced with a "(?P[^"]+)"' + ) +) +def the_body_value_will_have_been_replaced( + response: requests.Response, + value: str, +) -> None: + """The body value for "$.one" will have been replaced with a value.""" + data = response.json() + logger.info("Response JSON: %r", data) + + assert "one" in data, 'Response body does not contain key "one"' + assert value in GENERATOR_PATTERN, f"Unknown generator type {value!r}" + assert GENERATOR_PATTERN[value](data["one"]) + + +@then( + parsers.re( + r'the body value for "\$\.one" will have been replaced with "(?P[^"]+)"' + ) +) +def the_body_value_will_have_been_replaced_with_value( + response: requests.Response | Provider, +) -> None: + """The body value for "$.one" will have been replaced.""" + assert isinstance(response, requests.Response) + data = response.json() + logger.info("Response JSON: %r", data) + + assert "one" in data, 'Response body does not contain key "one"' + assert (url := SERVER_URL.get()) + logger.info("Server URL: %r", url) + # Note: IPv6 requires the square brackets, but there is currently a bug in + # the mock server that may result in the brackets being omitted. + url_pattern = re.escape(url).replace( + r"localhost", r"(127\.0\.0\.1|\[?::1\]?|localhost)" + ) + logger.info("URL Pattern: %r", url_pattern) + assert ( + re.match( + url_pattern, + data["one"], + ) + is not None + ) diff --git a/tests/compatibility_suite/test_v4_matching_rules.py b/tests/compatibility_suite/test_v4_matching_rules.py new file mode 100644 index 000000000..f7f4ea741 --- /dev/null +++ b/tests/compatibility_suite/test_v4_matching_rules.py @@ -0,0 +1,498 @@ +"""V4 matching rules tests.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any +from urllib.parse import parse_qs + +import pytest +import requests +from pytest_bdd import ( + given, + parsers, + scenario, + then, + when, +) + +from pact import Pact, Verifier +from tests.compatibility_suite.util import ( + FIXTURES_ROOT, + parse_horizontal_table, +) +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, +) +from tests.compatibility_suite.util.provider import Provider + +if TYPE_CHECKING: + from collections.abc import Callable + + from pact.error import Mismatch + +TEST_PACT_FILE_DIRECTORY = Path(Path(__file__).parent / "pacts") +EXT_TO_CONTENT_TYPE = { + "jpg": "image/jpeg", + "pdf": "application/pdf", + "json": "application/json", +} + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def pact() -> Pact: + return Pact( + "v4-matching-rules-consumer", + "v4-matching-rules-provider", + ).with_specification("V4") + + +@pytest.fixture +def verifier() -> Verifier: + return Verifier("v4-matching-rules-provider") + + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports a ArrayContains matcher (negative case)", +) +def test_supports_a_arraycontains_matcher_negative_case() -> None: + """Supports a ArrayContains matcher (negative case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports a EachValue matcher (negative case)", +) +def test_supports_a_eachvalue_matcher_negative_case() -> None: + """Supports a EachValue matcher (negative case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports a not empty matcher (negative case 2, types are different)", +) +def test_supports_a_not_empty_matcher_negative_case_2_types_are_different() -> None: + """Supports a not empty matcher (negative case 2, types are different).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports a not empty matcher (negative case)", +) +def test_supports_a_not_empty_matcher_negative_case() -> None: + """Supports a not empty matcher (negative case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports a not empty matcher (positive case)", +) +def test_supports_a_not_empty_matcher_positive_case() -> None: + """Supports a not empty matcher (positive case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports a not empty matcher with binary data (negative case)", +) +def test_supports_a_not_empty_matcher_with_binary_data_negative_case() -> None: + """Supports a not empty matcher with binary data (negative case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports a not empty matcher with binary data (positive case)", +) +def test_supports_a_not_empty_matcher_with_binary_data_positive_case() -> None: + """Supports a not empty matcher with binary data (positive case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports a semver matcher (negative case)", +) +def test_supports_a_semver_matcher_negative_case() -> None: + """Supports a semver matcher (negative case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports a semver matcher (positive case)", +) +def test_supports_a_semver_matcher_positive_case() -> None: + """Supports a semver matcher (positive case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports a status code matcher (negative case)", +) +def test_supports_a_status_code_matcher_negative_case() -> None: + """Supports a status code matcher (negative case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports a status code matcher (positive case)", +) +def test_supports_a_status_code_matcher_positive_case() -> None: + """Supports a status code matcher (positive case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports an ArrayContains matcher (positive case)", +) +def test_supports_an_arraycontains_matcher_positive_case() -> None: + """Supports an ArrayContains matcher (positive case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports an EachKey matcher (negative case)", +) +def test_supports_an_eachkey_matcher_negative_case() -> None: + """Supports an EachKey matcher (negative case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports an EachKey matcher (positive case)", +) +def test_supports_an_eachkey_matcher_positive_case() -> None: + """Supports an EachKey matcher (positive case).""" + + +@scenario( + "definition/features/V4/matching_rules.feature", + "Supports an EachValue matcher (positive case)", +) +def test_supports_an_eachvalue_matcher_positive_case() -> None: + """Supports an EachValue matcher (positive case).""" + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.re( + r"^(" + r"a request is received with the following:|" + r"the following requests are received:" + r")$" + ), + target_fixture="request_calls", +) +def a_request_is_received_with_the_following( + datatable: list[list[str]], +) -> list[Callable[[str], requests.Response]]: + """A request is received with the following:.""" + data = parse_horizontal_table(datatable) + assert len(data) > 0, "Expected at least one row in the table" + + body: Any + request_calls: list[Callable[[str], requests.Response]] = [] + for row in data: + content_type = row.pop("content type", None) + + if body := row.pop("body", None): + if body.startswith("JSON: "): + content_type = content_type or "application/json" + body = body.replace("JSON: ", "") + elif body.startswith("file: "): + content_type = ( + content_type or EXT_TO_CONTENT_TYPE[body.rsplit(".")[-1].lower()] + ) + body = (FIXTURES_ROOT / body.replace("file: ", "")).read_bytes() + elif body == "EMPTY": + body = None + + query: dict[str, list[str]] = ( + parse_qs(s) if (s := row.pop("query", None)) else {} + ) + headers = ( + dict(s.split(": ") for s in hs.strip("'").split("; ")) + if (hs := row.pop("headers", None)) + else {} + ) + + # Ignore description field + row.pop("desc", None) + + if row: + msg = f"Unexpected extra columns in table: {row!r}" + raise ValueError(msg) + + logger.debug( + "Configured POST request: %r", + { + "body": body, + "content_type": content_type, + "query": query, + "headers": headers, + }, + ) + + request_calls.append( + lambda url, # type: ignore[misc] + body=body, + content_type=content_type, + headers=headers, + query=query: requests.post( + url, + body, + timeout=2, + headers={ + **({"Content-Type": content_type} if content_type else {}), + **(headers), + }, + params=query, + ) + ) + + return request_calls + + +@given("an expected request configured with the following:") +def an_expected_request_configured_with( + pact: Pact, + datatable: list[list[str]], +) -> None: + """An expected request configured with.""" + data = parse_horizontal_table(datatable) + assert len(data) == 1, "Expected exactly one row in the table" + + interaction = InteractionDefinition( + method="POST", + path="/", + **data[0], # type: ignore[arg-type] + ) + interaction.add_to_pact(pact, "a matching rules request") + + +@given("an expected response configured with the following:") +def an_expected_response_configured_with_the_following( + pact: Pact, + datatable: list[list[str]], + tmp_path: Path, + verifier: Verifier, +) -> None: + """An expected response configured with the following.""" + data = parse_horizontal_table(datatable) + assert len(data) == 1, "Expected exactly one row in the table" + row = data[0] + + interaction = InteractionDefinition( + method="POST", + path="/", + status=row["status"], + response_matching_rules=row["matching rules"], + ) + interaction.add_to_pact(pact, "a matching rules response") + (tmp_path / "pacts").mkdir(exist_ok=True, parents=True) + pact.write_file(tmp_path / "pacts") + + with ( + tmp_path + / "pacts" + / "v4-matching-rules-consumer-v4-matching-rules-provider.json" + ).open( + "r", + encoding="utf-8", + ) as f: + for line in f: + logger.info("Pact file: %s", line.rstrip()) + + verifier.add_source(tmp_path / "pacts") + + +@given( + parsers.re(r"a status (?P\d{3}) response is received"), + target_fixture="provider", +) +def a_response_is_received( + status_code: str, + verifier: Verifier, +) -> Provider: + """A response is received.""" + provider = Provider() + interaction = InteractionDefinition( + method="POST", + path="/", + response=status_code, + ) + provider.add_interaction(interaction) + verifier.add_transport(url=provider.url) + return provider + + +################################################################################ +## When +################################################################################ + + +@when( + "the response is compared to the expected one", + target_fixture="verifier_result", +) +def the_response_is_compared_to_the_expected_one( + provider: Provider, + verifier: Verifier, +) -> tuple[Verifier, Exception | None]: + """The response is compared to the expected one.""" + with provider: + try: + verifier.verify() + except Exception as e: # noqa: BLE001 + return verifier, e + return verifier, None + + +@when( + parsers.re(r"the (request is|requests are) compared to the expected one"), + target_fixture="mismatches", +) +def the_request_is_compared_to_the_expected_one( + pact: Pact, + request_calls: list[Callable[[str], requests.Response]], +) -> list[Mismatch]: + """The request is compared to the expected one.""" + with pact.serve(raises=False) as srv: + for f in request_calls: + f(str(srv.url)) + + return srv.mismatches + + +################################################################################ +## Then +################################################################################ + + +@then( + parsers.re(r"the comparison should (?P(NOT )?)be OK"), + converters={"negated": lambda s: s == "NOT "}, +) +def the_comparison_should_be_ok( + negated: bool, # noqa: FBT001 + mismatches: list[Mismatch], +) -> None: + """The comparison should be OK.""" + if negated: + assert len(mismatches) > 0 + else: + assert len(mismatches) == 0 + + +@then( + parsers.re( + r"the mismatches will contain a mismatch " + r'with error "(?P[^"]+)" -> "(?P.+)"$' + ) +) +def the_mismatches_will_contain_a_mismatch_with_error( + path: str, + message: str, + mismatches: list[Mismatch], +) -> None: + """The mismatches will contain a mismatch with error.""" + # To account for slight differences in wording between implementations + # we map some expected values here. + path, message = { + ( + "$", + "Expected [] (0 bytes) to not be empty", + ): ("/", "Expected body Present(28058 bytes, image/jpeg) but was empty"), + ( + "$.actions", + 'Variant at index 1 ({\\"href\\":\\"http://api.x.io/orders/42/items\\",' + '\\"method\\":\\"DELETE\\",\\"name\\":\\"delete-item\\",' + '\\"title\\":\\"Delete Item\\"}) was not found in the actual list', + ): ( + "$.actions", + 'Variant at index 1 ({"href":"http://api.x.io/orders/42/items",' + '"method":"DELETE","name":"delete-item","title":"Delete Item"}) was ' + "not found in the actual list", + ), + ( + "$.two", + "Type mismatch: Expected 'b' (String) " + 'to be the same type as [\\"b\\"] (Array)', + ): ( + "$.two", + "Type mismatch: Expected 'b' (String) " + 'to be the same type as ["b"] (Array)', + ), + }.get((path, message), (path, message)) + logger.info("Searching for mismatch with path=%r, error=%r", path, message) + for mismatch in mismatches: + for submismatch in getattr(mismatch, "mismatches", []): + logger.info("Checking submismatch: %r", submismatch) + if ( + (s_path := getattr(submismatch, "path", None)) + and path == s_path + and (s_message := getattr(submismatch, "mismatch", None)) + and message in s_message + ): + logger.info("Found matching submismatch: %r", submismatch) + return + + msg = f"Mismatch not found: path={path!r}, error={message!r}" + raise AssertionError(msg) + + +@then( + parsers.re(r"the response comparison should (?P(NOT )?)be OK"), + converters={"negated": lambda s: s == "NOT "}, +) +def the_response_comparison_should_be_maybe_ok( + negated: bool, # noqa: FBT001 + verifier_result: tuple[Verifier, Exception | None], +) -> None: + """The response comparison should maybe be OK.""" + _, result = verifier_result + if negated: + assert result is not None + else: + assert result is None + + +@then( + parsers.re( + r'the response mismatches will contain a "(?P[^"]+)" mismatch ' + r'with error "(?P.+)"$' + ) +) +def the_response_mismatches_will_contain_a_mismatch_with_error( + mismatch_type: str, + message: str, + verifier_result: tuple[Verifier, Exception | None], +) -> None: + """The response mismatches will contain a mismatch with error.""" + mismatch_type = {"status": "StatusMismatch"}[mismatch_type] + + verifier, mismatches = verifier_result + assert mismatches is not None, "Expected mismatches to be present" + for error in verifier.results["errors"]: + if (mismatch := error.get("mismatch")) and ( + mismatches := mismatch.get("mismatches") + ): + for submismatch in mismatches: + if submismatch.get( + "type" + ) == mismatch_type and message in submismatch.get("mismatch", ""): + logger.info("Found matching submismatch: %r", submismatch) + return + msg = f"Mismatch {mismatch_type!r} not found with error={message!r}" + raise AssertionError(msg) diff --git a/tests/compatibility_suite/test_v4_message_consumer.py b/tests/compatibility_suite/test_v4_message_consumer.py new file mode 100644 index 000000000..6901bd6ee --- /dev/null +++ b/tests/compatibility_suite/test_v4_message_consumer.py @@ -0,0 +1,148 @@ +"""Message consumer feature tests.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from pytest_bdd import ( + given, + parsers, + scenario, + then, +) + +from tests.compatibility_suite.util import PactInteractionTuple, string_to_int +from tests.compatibility_suite.util.consumer import ( + a_message_integration_is_being_defined_for_a_consumer_test, + the_pact_file_for_the_test_is_generated, +) + +if TYPE_CHECKING: + from pact.pact import AsyncMessageInteraction + + +@scenario( + "definition/features/V4/message_consumer.feature", + "Sets the type for the interaction", +) +def test_sets_the_type_for_the_interaction() -> None: + """Sets the type for the interaction.""" + + +@scenario( + "definition/features/V4/message_consumer.feature", + "Supports specifying a key for the interaction", +) +def test_supports_specifying_a_key_for_the_interaction() -> None: + """Supports specifying a key for the interaction.""" + + +@scenario( + "definition/features/V4/message_consumer.feature", + "Supports specifying the interaction is pending", +) +def test_supports_specifying_the_interaction_is_pending() -> None: + """Supports specifying the interaction is pending.""" + + +@scenario( + "definition/features/V4/message_consumer.feature", + "Supports adding comments", +) +def test_supports_adding_comments() -> None: + """Supports adding comments.""" + + +################################################################################ +## Given +################################################################################ + +a_message_integration_is_being_defined_for_a_consumer_test("V4") + + +@given( + parsers.re(r'a comment "(?P[^"]+)" is added to the message interaction') +) +def a_comment_is_added_to_the_message_interaction( + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + comment: str, +) -> None: + """A comment "{comment}" is added to the message interaction.""" + pact_interaction.interaction.add_text_comment(comment) + + +@given( + parsers.re(r'a key of "(?P[^"]+)" is specified for the message interaction') +) +def a_key_is_specified_for_the_message_interaction( + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + key: str, +) -> None: + """A key is specified for the HTTP interaction.""" + pact_interaction.interaction.set_key(key) + + +@given("the message interaction is marked as pending") +def the_message_interaction_is_marked_as_pending( + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], +) -> None: + """The message interaction is marked as pending.""" + pact_interaction.interaction.set_pending(pending=True) + + +################################################################################ +## When +################################################################################ + + +the_pact_file_for_the_test_is_generated() + + +################################################################################ +## Then +################################################################################ + + +@then( + parsers.re( + r"the (?P[^ ]+) interaction in the Pact file" + r" will have \"(?P[^\"]+)\" = '(?P[^']+)'" + ), + converters={"num": string_to_int}, +) +def the_interaction_in_the_pact_file_will_have_a_key_of( + pact_data: dict[str, Any], + num: int, + key: str, + value: str, +) -> None: + """The interaction in the Pact file will have a key of value.""" + assert "interactions" in pact_data + assert len(pact_data["interactions"]) >= num + interaction = pact_data["interactions"][num - 1] + assert key in interaction + value = json.loads(value) + if isinstance(value, list): + assert interaction[key] in value + else: + assert interaction[key] == value + + +@then( + parsers.re( + r"the (?P[^ ]+) interaction in the Pact file" + r' will have a type of "(?P[^"]+)"' + ), + converters={"num": string_to_int}, +) +def the_interaction_in_the_pact_file_will_container_provider_states( + pact_data: dict[str, Any], + num: int, + interaction_type: str, +) -> None: + """The interaction in the Pact file will container provider states.""" + assert "interactions" in pact_data + assert len(pact_data["interactions"]) >= num + interaction = pact_data["interactions"][num - 1] + assert interaction["type"] == interaction_type diff --git a/tests/compatibility_suite/test_v4_message_provider.py b/tests/compatibility_suite/test_v4_message_provider.py new file mode 100644 index 000000000..3be1dd9ee --- /dev/null +++ b/tests/compatibility_suite/test_v4_message_provider.py @@ -0,0 +1,78 @@ +""" +Basic HTTP provider feature test. +""" + +from __future__ import annotations + +import logging + +from pytest_bdd import scenario + +from tests.compatibility_suite.util.provider import ( + a_pact_file_for_message_is_to_be_verified, + a_pact_file_for_message_is_to_be_verified_with_comments, + a_provider_is_started_that_can_generate_the_message, + the_comment_will_have_been_printed_to_the_console, + the_name_of_the_test_will_be_displayed_as_the_original_test_name, + the_verification_is_run, + the_verification_results_will_contain_a_error, + the_verification_will_be_successful, + there_will_be_a_pending_error, +) + +logger = logging.getLogger(__name__) + + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V4/message_provider.feature", + "Verifying a pending message interaction", +) +def test_verifying_a_pending_message_interaction() -> None: + """ + Verifying a pending message interaction. + """ + + +@scenario( + "definition/features/V4/message_provider.feature", + "Verifying a message interaction with comments", +) +def test_verifying_a_message_interaction_with_comments() -> None: + """ + Verifying a message interaction with comments. + """ + + +################################################################################ +## Given +################################################################################ + + +a_provider_is_started_that_can_generate_the_message() +a_pact_file_for_message_is_to_be_verified("V4") +a_pact_file_for_message_is_to_be_verified_with_comments("V4") + + +################################################################################ +## When +################################################################################ + + +the_verification_is_run() + + +################################################################################ +## Then +################################################################################ + + +the_comment_will_have_been_printed_to_the_console() +the_name_of_the_test_will_be_displayed_as_the_original_test_name() +the_verification_results_will_contain_a_error() +the_verification_will_be_successful() +there_will_be_a_pending_error() diff --git a/tests/compatibility_suite/test_v4_provider.py b/tests/compatibility_suite/test_v4_provider.py new file mode 100644 index 000000000..c466ebcfb --- /dev/null +++ b/tests/compatibility_suite/test_v4_provider.py @@ -0,0 +1,123 @@ +""" +Basic HTTP provider feature test. +""" + +from __future__ import annotations + +import logging + +from pytest_bdd import given, parsers, scenario + +from tests.compatibility_suite.util import parse_horizontal_table +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, +) +from tests.compatibility_suite.util.provider import ( + a_pact_file_for_interaction_is_to_be_verified, + a_pact_file_for_interaction_is_to_be_verified_with_comments, + a_provider_is_started_that_returns_the_responses_from_interactions, + a_provider_is_started_that_returns_the_responses_from_interactions_with_changes, + the_comment_will_have_been_printed_to_the_console, + the_name_of_the_test_will_be_displayed_as_the_original_test_name, + the_verification_is_run, + the_verification_results_will_contain_a_error, + the_verification_will_be_successful, + there_will_be_a_pending_error, +) + +logger = logging.getLogger(__name__) + + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V4/http_provider.feature", + "Verifying a pending HTTP interaction", +) +def test_verifying_a_pending_http_interaction() -> None: + """ + Verifying a pending HTTP interaction. + """ + + +@scenario( + "definition/features/V4/http_provider.feature", + "Verifying a HTTP interaction with comments", +) +def test_verifying_a_http_interaction_with_comments() -> None: + """ + Verifying a HTTP interaction with comments. + """ + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.parse("the following HTTP interactions have been defined:"), + target_fixture="interaction_definitions", +) +def the_following_http_interactions_have_been_defined( + datatable: list[list[str]], +) -> dict[int, InteractionDefinition]: + """ + Parse the HTTP interactions table into a dictionary. + + The table columns are expected to be: + + - No + - method + - path + - query + - headers + - body + - response + - response headers + - response content + - response body + + The first row is ignored, as it is assumed to be the column headers. The + order of the columns is similarly ignored. + """ + logger.debug("Parsing interaction definitions") + + # Check that the table is well-formed + definitions = parse_horizontal_table(datatable) + assert len(definitions[0]) == 10, f"Expected 10 columns, got {len(definitions[0])}" + assert "No" in definitions[0], "'No' column not found" + + # Parse the table into a more useful format + interactions: dict[int, InteractionDefinition] = {} + for row in definitions: + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] + return interactions + + +a_pact_file_for_interaction_is_to_be_verified("V4") +a_pact_file_for_interaction_is_to_be_verified_with_comments("V4") +a_provider_is_started_that_returns_the_responses_from_interactions() +a_provider_is_started_that_returns_the_responses_from_interactions_with_changes() + +################################################################################ +## When +################################################################################ + + +the_verification_is_run() + + +################################################################################ +## Then +################################################################################ + + +the_comment_will_have_been_printed_to_the_console() +the_name_of_the_test_will_be_displayed_as_the_original_test_name() +the_verification_results_will_contain_a_error() +the_verification_will_be_successful() +there_will_be_a_pending_error() diff --git a/tests/compatibility_suite/util/__init__.py b/tests/compatibility_suite/util/__init__.py new file mode 100644 index 000000000..70de802a1 --- /dev/null +++ b/tests/compatibility_suite/util/__init__.py @@ -0,0 +1,296 @@ +""" +Utility functions to help with testing. + +## Sharing PyTest BDD Steps + +The PyTest BDD library does some 'magic' to make the given/when/then steps +available in the test context. This is done by inspecting the stack frame of the +calling function and injecting the step definition function as a fixture. + +This is a problem when sharing steps between different test suites, as the stack +frame is different. Fortunately, PyTest BDD allows us to specify the stack level +to inspect, so we can use that to our advantage with the following pattern: + +```python +def some_step(stacklevel: int = 1) -> None: + @when(..., stacklevel=stacklevel + 1) + def _(): + # Step definition goes here +``` +""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +import typing +from collections.abc import Collection, Mapping +from datetime import date, datetime, time +from pathlib import Path +from typing import Any, Generic, TypeVar + +from multidict import MultiDict + +if typing.TYPE_CHECKING: + from pact.pact import Pact + +logger = logging.getLogger(__name__) +SUITE_ROOT = Path(__file__).parent.parent / "definition" +FIXTURES_ROOT = SUITE_ROOT / "fixtures" + +_T = TypeVar("_T") + + +class PactInteractionTuple(Generic[_T]): + """ + Pact and interaction tuple. + + A number of steps in the compatibility suite require one or both of a `Pact` + and an `Interaction` subclass. This named tuple is used to pass these + objects around more easily. + + /// note + This should be simplified in the future to simply being a + [`NamedTuple`][typing.NamedTuple]; however, earlier versions of Python do + not support inheriting from multiple classes, thereby preventing `class + PactInteractionTuple(NamedTuple, Generic[_T])` (even if + [`Generic[_T]`][typing.Generic] serves no purpose other than to allow type + hinting). + /// + """ + + def __init__(self, pact: Pact, interaction: _T) -> None: + """ + Instantiate the tuple. + """ + self.pact = pact + self.interaction = interaction + + +def string_to_int(word: str) -> int: + """ + Convert a word to an integer. + + The word can be a number, or a word representing a number. + + Args: + word: The word to convert. + + Returns: + The integer value of the word. + + Raises: + ValueError: If the word cannot be converted to an integer. + """ + try: + return int(word) + except ValueError: + pass + + try: + return { + "first": 1, + "second": 2, + "third": 3, + "fourth": 4, + "fifth": 5, + "sixth": 6, + "seventh": 7, + "eighth": 8, + "ninth": 9, + "1st": 1, + "2nd": 2, + "3rd": 3, + "4th": 4, + "5th": 5, + "6th": 6, + "7th": 7, + "8th": 8, + "9th": 9, + }[word] + except KeyError: + pass + + msg = f"Unable to convert {word!r} to an integer" + raise ValueError(msg) + + +def truncate(data: str | bytes) -> str: + """ + Truncate a large string or bytes object. + + This is useful for printing large strings or bytes objects in tests. + """ + if len(data) <= 256: + if isinstance(data, str): + return f"{data}" + return data.decode("utf-8", "backslashreplace") + + length = len(data) + if isinstance(data, str): + checksum = hashlib.sha256(data.encode()).hexdigest() + return ( + '"' + + data[:128] + + "⋯" + + data[-128:] + + '"' + + f" ({length} bytes, sha256={checksum[:7]})" + ) + + checksum = hashlib.sha256(data).hexdigest() + return ( + 'b"' + + data[:8].decode("utf-8", "backslashreplace") + + "⋯" + + data[-8:].decode("utf-8", "backslashreplace") + + '"' + + f" ({length} bytes, sha256={checksum[:7]})" + ) + + +def parse_horizontal_table(content: list[list[str]]) -> list[dict[str, str]]: + """ + Parse a table into a list of dictionaries. + + The table is expected to be in the following format: + + ```markdown + | key1 | key2 | key3 | + | val1 | val2 | val3 | + ``` + + The parsing of the Markdown table into a list of lists is first done by + the `pytest-bdd` library. This function then converts this into a list of + dictionaries. + + Args: + content: + The table contents as parsed by `pytest-bdd`. + + Returns: + A list of dictionaries, where each dictionary represents a row in the + table. + """ + if len(content) < 2: + msg = f"Expected at least two rows in the table, got {len(content)}" + raise ValueError(msg) + + return [dict(zip(content[0], row, strict=True)) for row in content[1:]] + + +def parse_vertical_table(content: list[list[str]]) -> dict[str, str]: + """ + Parse a table into a single dictionary. + + The table is expected to be in the following format: + + ```markdown + | key1 | val1 | + | key2 | val2 | + | key3 | val3 | + ``` + + The parsing of the Markdown table into a list of lists is first done by + the `pytest-bdd` library. This function then converts this into a single + dictionary for easier access. + + Args: + content: + The table contents as parsed by `pytest-bdd`. + + Returns: + A dictionary, where each key is a column in the table + """ + if len(content[0]) != 2: + msg = f"Expected exactly two columns in the table, got {len(content[0])}" + raise ValueError(msg) + + return {row[0]: row[1] for row in content} + + +def serialize(obj: Any) -> Any: # noqa: ANN401, PLR0911 + """ + Convert an object to a dictionary. + + This function converts an object to a dictionary by calling `vars` on the + object. This is useful for classes which are not otherwise serializable + using `json.dumps`. + + A few special cases are handled: + + - If the object is a `datetime` object, it is converted to an ISO 8601 + string. + - All forms of [`Mapping`][collections.abc.Mapping] are converted to + dictionaries. + - All forms of [`Collection`][collections.abc.Collection] are converted to + lists. + + All other types are converted to strings using the `repr` function. + """ + if isinstance(obj, (datetime, date, time)): + return obj.isoformat() + + # Basic types which are already serializable + if isinstance(obj, (str, int, float, bool, type(None))): + return obj + + # Bytes + if isinstance(obj, bytes): + return { + "__class__": obj.__class__.__name__, + "data": base64.b64encode(obj).decode("utf-8"), + } + + # Collections + if isinstance(obj, Mapping): + return {k: serialize(v) for k, v in obj.items()} + + if isinstance(obj, Collection): + return [serialize(v) for v in obj] + + # Objects + if hasattr(obj, "__dict__"): + return { + "__class__": obj.__class__.__name__, + "__module__": obj.__class__.__module__, + **{k: serialize(v) for k, v in obj.__dict__.items()}, + } + + return repr(obj) + + +def parse_headers(headers: str) -> MultiDict[str]: + """ + Parse the headers. + + The headers are in the format: + + ```text + 'X-A: 1', 'X-B: 2', 'X-A: 3' + ``` + + As headers can be repeated, the result is a MultiDict. + """ + kvs: list[tuple[str, str]] = [] + for header in headers.split(", "): + k, _sep, v = header.strip("'").partition(": ") + kvs.append((k, v)) + return MultiDict(kvs) + + +def parse_rules(matching_rules: str) -> str: + """ + Parse the matching rules. + + The matching rules are in one of two formats: + + - An explicit JSON object, prefixed by `JSON: `. + - A fixture file which contains the matching rules. + """ + if matching_rules.startswith("JSON: "): + return matching_rules[6:] + + with (FIXTURES_ROOT / matching_rules).open("r") as file: + return file.read() diff --git a/tests/compatibility_suite/util/consumer.py b/tests/compatibility_suite/util/consumer.py new file mode 100644 index 000000000..673ebbca9 --- /dev/null +++ b/tests/compatibility_suite/util/consumer.py @@ -0,0 +1,781 @@ +""" +Utility functions for the consumer tests. +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import TYPE_CHECKING, Any, TypeGuard + +import pytest +import requests +from pytest_bdd import given, parsers, then, when +from yarl import URL + +from pact import Pact +from pact.error import ( + BodyMismatch, + BodyTypeMismatch, + HeaderMismatch, + MetadataMismatch, + Mismatch, + MissingRequest, + PathMismatch, + QueryMismatch, + RequestMismatch, + RequestNotFound, + StatusMismatch, +) +from tests.compatibility_suite.util import ( + FIXTURES_ROOT, + PactInteractionTuple, + parse_horizontal_table, + string_to_int, + truncate, +) + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + from pact.interaction._async_message_interaction import AsyncMessageInteraction + from pact.pact import PactServer + from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, + ) + +logger = logging.getLogger(__name__) + + +MISMATCH_MAP: dict[str, type[Mismatch]] = { + "query": QueryMismatch, + "header": HeaderMismatch, + "body": BodyMismatch, + "body-content-type": BodyTypeMismatch, +} + + +def _mismatch_with_path( + mismatch: Mismatch, +) -> TypeGuard[MissingRequest | RequestNotFound | RequestMismatch | BodyMismatch]: + """ + Check if a mismatch has a `path` attribute. + + This function is used to check if the mismatch in question is one of the + variants that have a `path` attribute. This has little purpose at runtime, + but is useful for type checking. + """ + return isinstance( + mismatch, (MissingRequest, RequestNotFound, RequestMismatch, BodyMismatch) + ) + + +def _mismatch_with_mismatch( + mismatch: Mismatch, +) -> TypeGuard[ + PathMismatch + | StatusMismatch + | QueryMismatch + | HeaderMismatch + | BodyTypeMismatch + | BodyMismatch + | MetadataMismatch +]: + """ + Check if a mismatch has a `mismatch` attribute. + + This function is used to check if the mismatch in question is one of the + variants that have a `mismatch` attribute. This has little purpose at runtime, + but is useful for type checking. + """ + return isinstance( + mismatch, + ( + PathMismatch, + StatusMismatch, + QueryMismatch, + HeaderMismatch, + BodyTypeMismatch, + BodyMismatch, + MetadataMismatch, + ), + ) + + +################################################################################ +## Given +################################################################################ + + +def a_message_integration_is_being_defined_for_a_consumer_test( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a message (integration|interaction) " + r"is being defined for a consumer test" + ), + target_fixture="pact_interaction", + stacklevel=stacklevel + 1, + ) + def _() -> PactInteractionTuple[AsyncMessageInteraction]: + """ + A message integration is being defined for a consumer test. + """ + logger.info("Creating a message interaction") + pact = Pact("consumer", "provider") + pact.with_specification(version) + return PactInteractionTuple( + pact, + pact.upon_receiving("an asynchronous message", "Async"), + ) + + +################################################################################ +## When +################################################################################ + + +def the_mock_server_is_started_with_interactions( + version: str, + stacklevel: int = 1, +) -> None: + @when( + parsers.re( + r"the mock server is started" + r" with interactions?" + r' "?(?P((\d+)(,\s)?)+)"?', + ), + converters={"ids": lambda s: list(map(int, s.split(",")))}, + target_fixture="srv", + stacklevel=stacklevel + 1, + ) + def _( + ids: list[int], + interaction_definitions: dict[int, InteractionDefinition], + ) -> Generator[PactServer, Any, None]: + """The mock server is started with interactions.""" + logger.info("Starting Pact mock server") + pact = Pact("consumer", "provider") + pact.with_specification(version) + for iid in ids: + definition = interaction_definitions[iid] + logger.info("Adding interaction %s", iid) + definition.add_to_pact(pact, f"interaction {iid}") + + with pact.serve(raises=False) as srv: + yield srv + + +def the_mock_server_is_started_with_interaction_n_but_with_the_following_changes( + version: str, + stacklevel: int = 1, +) -> None: + @when( + parsers.re( + r"the mock server is started" + r" with interaction (?P\d+)" + r" but with the following changes?:", + re.DOTALL, + ), + converters={"iid": int}, + target_fixture="srv", + stacklevel=stacklevel + 1, + ) + def _( + iid: int, + interaction_definitions: dict[int, InteractionDefinition], + datatable: list[list[str]], + ) -> Generator[PactServer, Any, None]: + """The mock server is started with interactions.""" + pact = Pact("consumer", "provider") + pact.with_specification(version) + definition = interaction_definitions[iid] + changes = parse_horizontal_table(datatable) + definition.update(**changes[0]) # type: ignore[arg-type] + logger.info("Adding modified interaction %s", iid) + definition.add_to_pact(pact, f"interaction {iid}") + + with pact.serve(raises=False) as srv: + yield srv + + +def request_n_is_made_to_the_mock_server(stacklevel: int = 1) -> None: + @when( + parsers.re( + r"request (?P\d+) is made to the mock server", + ), + converters={"request_id": int}, + target_fixture="response", + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + request_id: int, + srv: PactServer, + ) -> requests.Response: + """ + Request n is made to the mock server. + """ + definition = interaction_definitions[request_id] + if ( + definition.body + and definition.body.mime_type + and "Content-Type" not in definition.headers + ): + definition.headers.add("Content-Type", definition.body.mime_type) + + assert definition.method is not None, "Method not defined" + assert definition.path is not None, "Path not defined" + + return requests.request( + definition.method, + str(srv.url.with_path(definition.path)), + params=( + URL.build(query_string=definition.query).query + if definition.query + else None + ), + headers=definition.headers or None, # type: ignore[arg-type] + data=definition.body.bytes if definition.body else None, + timeout=5, + ) + + +def request_n_is_made_to_the_mock_server_with_the_following_changes( + stacklevel: int = 1, +) -> None: + @when( + parsers.re( + r"request (?P\d+) is made to the mock server" + r" with the following changes?:", + re.DOTALL, + ), + converters={"request_id": int}, + target_fixture="response", + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + request_id: int, + datatable: list[list[str]], + srv: PactServer, + ) -> requests.Response: + """ + Request n is made to the mock server with changes. + + The content is a markdown table with a subset of the columns defining the + definition (as in the given step). + """ + definition = interaction_definitions[request_id] + changes = parse_horizontal_table(datatable) + assert len(changes) == 1, "Expected exactly one row in the table" + definition.update(**changes[0]) # type: ignore[arg-type] + + if ( + definition.body + and definition.body.mime_type + and "Content-Type" not in definition.headers + ): + definition.headers.add("Content-Type", definition.body.mime_type) + + assert definition.method is not None, "Method not defined" + assert definition.path is not None, "Path not defined" + + return requests.request( + definition.method, + str(srv.url.with_path(definition.path)), + params=( + URL.build(query_string=definition.query).query + if definition.query + else None + ), + headers=definition.headers or None, # type: ignore[arg-type] + data=definition.body.bytes if definition.body else None, + timeout=5, + ) + + +def the_pact_test_is_done(stacklevel: int = 1) -> None: + @when("the pact test is done", stacklevel=stacklevel + 1) + def _() -> None: + """ + The pact test is done. + """ + + +def the_pact_file_for_the_test_is_generated(stacklevel: int = 1) -> None: + @when( + "the Pact file for the test is generated", + target_fixture="pact_data", + stacklevel=stacklevel + 1, + ) + def _( + tmp_path: Path, + pact_interaction: PactInteractionTuple[Any], + ) -> dict[str, Any]: + """The Pact file for the test is generated.""" + pact_interaction.pact.write_file(tmp_path) + with (tmp_path / "consumer-provider.json").open("r") as file: + return json.load(file) + + +################################################################################ +## Then +################################################################################ + + +def a_response_is_returned(stacklevel: int = 1) -> None: + @then( + parsers.re( + r"a (?P\d+) (success|error) response is returned", + ), + converters={"code": int}, + stacklevel=stacklevel + 1, + ) + def _( + response: requests.Response, + code: int, + srv: PactServer, + ) -> None: + """ + A response is returned. + """ + logger.info( + "Request Information:\n%s", + json.dumps( + { + "method": response.request.method, + "url": response.request.url, + "headers": dict(**response.request.headers), + "body": truncate(response.request.body) + if response.request.body + else None, + }, + indent=2, + ), + ) + msg = "\n".join([ + "Mismatches:", + *(f" ({i + 1}) {m}" for i, m in enumerate(srv.mismatches)), + ]) + logger.info(msg) + assert response.status_code == code + + +def the_payload_will_contain_the_json_document(stacklevel: int = 1) -> None: + @then( + parsers.re( + r'the payload will contain the "(?P[^"]+)" JSON document', + ), + stacklevel=stacklevel + 1, + ) + def _( + response: requests.Response, + file: str, + ) -> None: + """ + The payload will contain the JSON document. + """ + path = FIXTURES_ROOT / f"{file}.json" + assert response.json() == json.loads(path.read_text()) + + +def the_content_type_will_be_set_as(stacklevel: int = 1) -> None: + @then( + parsers.re( + r'the content type will be set as "(?P[^"]+)"', + ), + stacklevel=stacklevel + 1, + ) + def _( + response: requests.Response, + content_type: str, + ) -> None: + assert "Content-Type" in response.headers, "Content-Type not set" + assert response.headers["Content-Type"] == content_type, "Content-Type mismatch" + + +def the_mock_server_status_will_be(stacklevel: int = 1) -> None: + @then( + parsers.re(r"the mock server status will (?P(NOT )?)be OK"), + converters={"negated": lambda s: s == "NOT "}, + stacklevel=stacklevel + 1, + ) + def _( + srv: PactServer, + negated: bool, # noqa: FBT001 + ) -> None: + """ + The mock server status will be. + """ + assert srv.matched is not negated + + +def the_mock_server_status_will_be_an_expected_but_not_received_error_for_interaction_n( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"the mock server status will be" + r" an expected but not received error" + r" for interaction \{(?P\d+)\}", + ), + converters={"n": int}, + stacklevel=stacklevel + 1, + ) + def _( + srv: PactServer, + n: int, + interaction_definitions: dict[int, InteractionDefinition], + ) -> None: + """ + The mock server status will be an expected but not received error. + """ + assert srv.matched is False + assert len(srv.mismatches) > 0 + + for mismatch in srv.mismatches: + if ( + isinstance(mismatch, MissingRequest) + and mismatch.method == interaction_definitions[n].method + and mismatch.path == interaction_definitions[n].path + ): + return + pytest.fail("Expected mismatch not found") + + +def the_mock_server_status_will_be_an_unexpected_request_received_for_interaction_n( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"the mock server status will be" + r' an unexpected "(?P[^"]+)" request received error' + r" for interaction \{(?P\d+)\}", + ), + converters={"n": int}, + stacklevel=stacklevel + 1, + ) + def _( + srv: PactServer, + method: str, + n: int, + interaction_definitions: dict[int, InteractionDefinition], + ) -> None: + """ + The mock server status will be an expected but not received error. + """ + assert srv.matched is False + assert len(srv.mismatches) > 0 + + for mismatch in srv.mismatches: + if ( + isinstance(mismatch, RequestNotFound) + and mismatch.method == interaction_definitions[n].method + and mismatch.method == method + and mismatch.path == interaction_definitions[n].path + ): + return + pytest.fail("Expected mismatch not found") + + +def the_mock_server_status_will_be_an_unexpected_request_received_for_path( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"the mock server status will be" + r' an unexpected "(?P[^"]+)" request received error' + r' for path "(?P[^"]+)"', + ), + converters={"n": int}, + stacklevel=stacklevel + 1, + ) + def _( + srv: PactServer, + method: str, + path: str, + ) -> None: + """ + The mock server status will be an expected but not received. + """ + assert srv.matched is False + assert len(srv.mismatches) > 0 + + for mismatch in srv.mismatches: + if ( + isinstance(mismatch, RequestNotFound) + and mismatch.method == method + and mismatch.path == path + ): + return + pytest.fail("Expected mismatch not found") + + +def the_mock_server_status_will_be_mismatches(stacklevel: int = 1) -> None: + @then( + "the mock server status will be mismatches", + stacklevel=stacklevel + 1, + ) + def _( + srv: PactServer, + ) -> None: + """ + The mock server status will be mismatches. + """ + assert srv.matched is False + assert len(srv.mismatches) > 0 + + +def the_mismatches_will_contain_a_mismatch_with_the_error(stacklevel: int = 1) -> None: + @then( + parsers.re( + r'the mismatches will contain a "(?P[^"]+)" mismatch' + r' with error "(?P[^"]+)"', + ), + stacklevel=stacklevel + 1, + ) + def _( + srv: PactServer, + mismatch_type: str, + error: str, + ) -> None: + """ + The mismatches will contain a mismatch with the error. + """ + logger.info("Expecting mismatch: %s", mismatch_type) + logger.info("With error: %s", error) + for mismatch in srv.mismatches: + if isinstance(mismatch, RequestMismatch): + for sub_mismatch in mismatch.mismatches: + if ( + isinstance(sub_mismatch, MISMATCH_MAP[mismatch_type]) + and _mismatch_with_mismatch(sub_mismatch) + and error in sub_mismatch.mismatch + ): + return + pytest.fail("Expected mismatch not found") + + +def the_mismatches_will_contain_a_mismatch_with_path_with_the_error( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r'the mismatches will contain a "(?P[^"]+)" mismatch' + r' with path "(?P[^"]+)"' + r' with error "(?P[^"]+)"', + ), + stacklevel=stacklevel + 1, + ) + def _( + srv: PactServer, + mismatch_type: str, + path: str, + error: str, + ) -> None: + """ + The mismatches will contain a mismatch with the error. + """ + for mismatch in srv.mismatches: + assert isinstance(mismatch, RequestMismatch) + for sub_mismatch in mismatch.mismatches: + if ( + isinstance(sub_mismatch, MISMATCH_MAP[mismatch_type]) + and _mismatch_with_mismatch(sub_mismatch) + and sub_mismatch.mismatch == error + and _mismatch_with_path(sub_mismatch) + and sub_mismatch.path == path + ): + return + pytest.fail("Expected mismatch not found") + + +def the_mock_server_will_write_out_a_pact_file_for_the_interaction_when_done( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"the mock server will (?P(NOT )?)write out" + r" a Pact file for the interactions? when done", + ), + converters={"negated": lambda s: s == "NOT "}, + target_fixture="pact_file", + stacklevel=stacklevel + 1, + ) + def _( + srv: PactServer, + tmp_path: Path, + negated: bool, # noqa: FBT001 + ) -> dict[str, Any] | None: + """ + The mock server will write out a Pact file for the interaction when done. + """ + if not negated: + srv.write_file(tmp_path) + output = tmp_path / "consumer-provider.json" + assert output.is_file() + return json.load(output.open()) + return None + + +def the_pact_file_will_contain_n_interactions(stacklevel: int = 1) -> None: + @then( + parsers.re(r"the pact file will contain \{(?P\d+)\} interactions?"), + converters={"n": int}, + stacklevel=stacklevel + 1, + ) + def _( + pact_file: dict[str, Any], + n: int, + ) -> None: + """ + The pact file will contain n interactions. + """ + assert len(pact_file["interactions"]) == n + + +def the_nth_interaction_will_contain_the_document(stacklevel: int = 1) -> None: + @then( + parsers.re( + r"the \{(?P\w+)\} interaction response" + r' will contain the "(?P[^"]+)" document', + ), + converters={"n": string_to_int}, + stacklevel=stacklevel + 1, + ) + def _( + pact_file: dict[str, Any], + n: int, + file: str, + ) -> None: + """ + The nth interaction response will contain the document. + """ + file_path = FIXTURES_ROOT / file + if file.endswith(".json"): + assert pact_file["interactions"][n - 1]["response"]["body"] == json.load( + file_path.open(), + ) + + +def the_nth_interaction_request_will_be_for_method(stacklevel: int = 1) -> None: + @then( + parsers.re( + r"the \{(?P\w+)\} interaction request" + r' will be for a "(?P[A-Z]+)"', + ), + converters={"n": string_to_int}, + stacklevel=stacklevel + 1, + ) + def _( + pact_file: dict[str, Any], + n: int, + method: str, + ) -> None: + """ + The nth interaction request will be for a method. + """ + assert pact_file["interactions"][n - 1]["request"]["method"] == method + + +def the_nth_interaction_request_query_parameters_will_be(stacklevel: int = 1) -> None: + @then( + parsers.re( + r"the \{(?P\w+)\} interaction request" + r' query parameters will be "(?P[^"]+)"', + ), + converters={"n": string_to_int}, + stacklevel=stacklevel + 1, + ) + def _( + pact_file: dict[str, Any], + n: int, + query: str, + ) -> None: + """ + The nth interaction request query parameters will be. + """ + assert query == pact_file["interactions"][n - 1]["request"]["query"] + + +def the_nth_interaction_request_will_contain_the_header(stacklevel: int = 1) -> None: + @then( + parsers.re( + r"the \{(?P\w+)\} interaction request" + r' will contain the header "(?P[^"]+)"' + r' with value "(?P[^"]+)"', + ), + converters={"n": string_to_int}, + stacklevel=stacklevel + 1, + ) + def _( + pact_file: dict[str, Any], + n: int, + key: str, + value: str, + ) -> None: + """ + The nth interaction request will contain the header. + """ + expected = {key: value} + actual = pact_file["interactions"][n - 1]["request"]["headers"] + assert expected.keys() == actual.keys() + for k, v in expected.items(): + assert v == actual[k] or [v] == actual[k] + + +def the_nth_interaction_request_content_type_will_be(stacklevel: int = 1) -> None: + @then( + parsers.re( + r"the \{(?P\w+)\} interaction request" + r' content type will be "(?P[^"]+)"', + ), + converters={"n": string_to_int}, + stacklevel=stacklevel + 1, + ) + def _( + pact_file: dict[str, Any], + n: int, + content_type: str, + ) -> None: + """ + The nth interaction request will contain the header. + """ + assert ( + pact_file["interactions"][n - 1]["request"]["headers"]["Content-Type"] + == content_type + ) + + +def the_nth_interaction_request_will_contain_the_document(stacklevel: int = 1) -> None: + @then( + parsers.re( + r"the \{(?P\w+)\} interaction request" + r' will contain the "(?P[^"]+)" document', + ), + converters={"n": string_to_int}, + stacklevel=stacklevel + 1, + ) + def _( + pact_file: dict[str, Any], + n: int, + file: str, + ) -> None: + """ + The nth interaction request will contain the document. + """ + file_path = FIXTURES_ROOT / file + if file.endswith(".json"): + assert pact_file["interactions"][n - 1]["request"]["body"] == json.load( + file_path.open(), + ) + else: + assert ( + pact_file["interactions"][n - 1]["request"]["body"] + == file_path.read_text() + ) diff --git a/tests/compatibility_suite/util/interaction_definition.py b/tests/compatibility_suite/util/interaction_definition.py new file mode 100644 index 000000000..5b4e69b2c --- /dev/null +++ b/tests/compatibility_suite/util/interaction_definition.py @@ -0,0 +1,792 @@ +""" +Interaction definition. + +This module defines the `InteractionDefinition` class, which is used to help +parse the interaction definitions from the compatibility suite, and interact +with the `Pact` and `Interaction` classes. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import typing +import warnings +from typing import Any, Literal +from xml.etree import ElementTree as ET + +from multidict import MultiDict +from yarl import URL + +from pact.interaction import HttpInteraction, Interaction +from tests.compatibility_suite.util import ( + FIXTURES_ROOT, + parse_headers, + parse_rules, + truncate, +) + +if typing.TYPE_CHECKING: + from http.server import SimpleHTTPRequestHandler + from pathlib import Path + + from typing_extensions import Self + + from pact.interaction import Interaction + from pact.pact import Pact + from pact.types import Message + +logger = logging.getLogger(__name__) + + +class InteractionBody: + """ + Interaction body. + + The interaction body can be one of: + + - A file + - An arbitrary string + - A JSON document + - An XML document + """ + + def __init__(self, data: str) -> None: + """ + Instantiate the interaction body. + """ + self.string: str | None = None + self.bytes: bytes | None = None + self.mime_type: str | None = None + + if data.startswith("file: ") and data.endswith("-body.xml"): + self.parse_fixture(FIXTURES_ROOT / data[6:]) + return + + if data.startswith("file: "): + self.parse_file(FIXTURES_ROOT / data[6:]) + return + + if data.startswith("JSON: "): + self.string = data[6:] + self.bytes = self.string.encode("utf-8") + self.mime_type = "application/json" + return + + if data.startswith("XML: "): + self.string = data[5:] + self.bytes = self.string.encode("utf-8") + self.mime_type = "application/xml" + return + + self.bytes = data.encode("utf-8") + self.string = data + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return "".format( + ", ".join(str(k) + "=" + truncate(repr(v)) for k, v in vars(self).items()), + ) + + def parse_fixture(self, fixture: Path) -> None: + """ + Parse a fixture file. + + This is used to parse the fixture files that contain additional + metadata about the body (such as the content type). + """ + etree = ET.parse(fixture) # noqa: S314 + root = etree.getroot() + if not root or root.tag != "body": + msg = "Invalid XML fixture document" + raise ValueError(msg) + + contents = root.find("contents") + content_type = root.find("contentType") + if contents is None: + msg = "Invalid XML fixture document: no contents" + raise ValueError(msg) + if content_type is None: + msg = "Invalid XML fixture document: no contentType" + raise ValueError(msg) + self.string = typing.cast("str", contents.text) + + if eol := contents.attrib.get("eol", None): + if eol == "CRLF": + self.string = self.string.replace("\r\n", "\n") + self.string = self.string.replace("\n", "\r\n") + elif eol == "LF": + self.string = self.string.replace("\r\n", "\n") + + self.bytes = self.string.encode("utf-8") + self.mime_type = content_type.text + + def parse_file(self, file: Path) -> None: + """ + Load the contents of a file. + + The mime type is inferred from the file extension, and the contents + are loaded as a byte array, and optionally as a string. + """ + self.bytes = file.read_bytes() + with contextlib.suppress(UnicodeDecodeError): + self.string = file.read_text() + + if file.suffix == ".xml": + self.mime_type = "application/xml" + elif file.suffix == ".json": + self.mime_type = "application/json" + elif file.suffix == ".jpg": + self.mime_type = "image/jpeg" + elif file.suffix == ".pdf": + self.mime_type = "application/pdf" + else: + msg = "Unknown file type" + raise ValueError(msg) + + def add_to_interaction( + self, + interaction: Interaction, + ) -> None: + """ + Add a body to the interaction. + + This is a helper method that adds the body to the interaction. This + relies on Pact's intelligent understanding of whether it is dealing with + a request or response (which is determined through the use of + `will_respond_with`). + + Args: + body: + The body to add to the interaction. + + interaction: + The interaction to add the body to. + + """ + if self.string: + logger.info( + "with_body(%r, %r)", + truncate(self.string), + self.mime_type, + ) + interaction.with_body( + self.string, + self.mime_type, + ) + elif self.bytes: + logger.info( + "with_binary_body(%r, %r)", + truncate(self.bytes), + self.mime_type, + ) + interaction.with_binary_body( + self.bytes, + self.mime_type, + ) + else: + msg = "Unexpected body definition" + raise RuntimeError(msg) + + if self.mime_type and isinstance(interaction, HttpInteraction): + logger.info('set_header("Content-Type", %r)', self.mime_type) + interaction.set_header("Content-Type", self.mime_type) + + +class InteractionState: + """ + Provider state. + """ + + def __init__( + self, + name: str, + parameters: str | dict[str, Any] | None = None, + ) -> None: + """ + Instantiate the provider state. + """ + self.name = name + self.parameters: dict[str, Any] + if isinstance(parameters, str): + self.parameters = json.loads(parameters) + else: + self.parameters = parameters or {} + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return "".format( + ", ".join(str(k) + "=" + truncate(repr(v)) for k, v in vars(self).items()), + ) + + def as_dict(self) -> dict[str, str | dict[str, Any]]: + """ + Convert the provider state to a dictionary. + """ + return {"name": self.name, "parameters": self.parameters} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """ + Convert a dictionary to a provider state. + """ + return cls(**data) + + +class InteractionDefinition: + """ + Interaction definition. + + This is a dictionary that represents a single interaction. It is used to + parse the HTTP interactions table into a more useful format. + """ + + def __init__( + self, + *, + metadata: dict[str, Any] | None = None, + **kwargs: str, + ) -> None: + """ + Initialise the interaction definition. + + As the interaction definitions are parsed from a Markdown table, + values are expected to be strings and must be converted to the correct + type. + + The _only_ exception to this is the `metadata` key, which expects + a dictionary. + """ + # A common pattern used in the tests is to have a table with the 'base' + # definitions, and have tests modify these definitions as need be. As a + # result, the `__init__` method is designed to set all the values to + # defaults, and the `update` method is used to update the values. + if type_ := kwargs.pop("type", "HTTP"): + if type_ not in ("HTTP", "Sync", "Async"): + msg = f"Invalid value for 'type': {type_}" + raise ValueError(msg) + self.type: Literal["HTTP", "Sync", "Async"] = type_ # type: ignore[assignment] + + # General properties shared by all interaction types + self.id: int | None = None + self.description: str | None = None + self.states: list[InteractionState] = [] + self.metadata: dict[str, Any] | None = None + self.pending: bool = False + self.is_pending: bool = False + self.test_name: str | None = None + self.text_comments: list[str] = [] + self.comments: dict[str, str] = {} + + # Request properties + self.method: str | None = None + self.path: str | None = None + self.response: int | None = None + self.query: str | None = None + self.headers: MultiDict[str] = MultiDict() + self.body: InteractionBody | None = None + self.matching_rules: str | None = None + self.generators: str | None = None + + # Response properties + self.response_headers: MultiDict[str] = MultiDict() + self.response_body: InteractionBody | None = None + self.response_matching_rules: str | None = None + self.response_generators: str | None = None + + self.update(metadata=metadata, **kwargs) + + def update(self, *, metadata: dict[str, Any] | None = None, **kwargs: str) -> None: + """ + Update the interaction definition. + + This is a convenience method that allows the interaction definition to + be updated with new values. + """ + kwargs = self._update_shared(metadata, **kwargs) + kwargs = self._update_request(**kwargs) + kwargs = self._update_response(**kwargs) + + if len(kwargs) > 0: + msg = f"Unexpected arguments: {kwargs.keys()}" + raise TypeError(msg) + + def _update_shared( + self, + metadata: dict[str, Any] | None = None, + **kwargs: str, + ) -> dict[str, str]: + """ + Update the shared properties of the interaction. + + Note that the following properties are not supported and must be + modified directly: + + - `states` + - `text_comments` + - `comments` + + Args: + metadata: + Metadata for the interaction. + + kwargs: + Remaining keyword arguments, which are: + + - `No`: Interaction ID. Used purely for debugging purposes. + - `description`: Description of the interaction (used by + asynchronous messages) + - `pending`: Whether the interaction is pending. + - `test_name`: Test name for the interaction. + + Returns: + The remaining keyword arguments. + """ + if interaction_id := kwargs.pop("No", None): + self.id = int(interaction_id) + + if description := kwargs.pop("description", None): + self.description = description + + if "states" in kwargs: + msg = "Unsupported. Modify the 'states' property directly." + raise ValueError(msg) + + if metadata: + self.metadata = metadata + + if "pending" in kwargs: + self.pending = kwargs.pop("pending") == "true" + + if test_name := kwargs.pop("test_name", None): + self.test_name = test_name + + if "text_comments" in kwargs: + msg = "Unsupported. Modify the 'text_comments' property directly." + raise ValueError(msg) + + if "comments" in kwargs: + msg = "Unsupported. Modify the 'comments' property directly." + raise ValueError(msg) + + return kwargs + + def _update_request(self, **kwargs: str) -> dict[str, str]: # noqa: C901 + """ + Update the request properties of the interaction. + + Args: + kwargs: + Remaining keyword arguments, which are: + + - `method`: Request method. + - `path`: Request path. + - `query`: Query parameters. + - `headers`: Request headers. + - `raw_headers`: Request headers. + - `body`: Request body. + - `content_type`: Request content type. + - `matching_rules`: Request matching rules. + - `generators`: Request generators. + + """ + if method := kwargs.pop("method", None): + self.method = method + + if path := kwargs.pop("path", None): + self.path = path + + if query := kwargs.pop("query", None): + self.query = query + + if headers := kwargs.pop("headers", None): + self.headers = parse_headers(headers) + + if headers := ( + kwargs.pop("raw headers", None) or kwargs.pop("raw_headers", None) + ): + self.headers = parse_headers(headers) + + if body := kwargs.pop("body", None): + # When updating the body, we _only_ update the body content, not + # the content type. + orig_content_type = self.body.mime_type if self.body else None + self.body = InteractionBody(body) + self.body.mime_type = self.body.mime_type or orig_content_type + + if content_type := ( + kwargs.pop("content_type", None) or kwargs.pop("content type", None) + ): + if self.body is None: + self.body = InteractionBody("") + self.body.mime_type = content_type + + if matching_rules := ( + kwargs.pop("matching_rules", None) or kwargs.pop("matching rules", None) + ): + self.matching_rules = parse_rules(matching_rules) + + if generators := kwargs.pop("generators", None): + self.generators = parse_rules(generators) + + return kwargs + + def _update_response(self, **kwargs: str) -> dict[str, str]: + """ + Update the response properties of the interaction. + + Args: + kwargs: + Remaining keyword arguments, which are: + + - `response`: Response status code. + - `response_headers`: Response headers. + - `response_content`: Response content type. + - `response_body`: Response body. + - `response_matching_rules`: Response matching rules. + - `response_generators`: Response generators. + + Returns: + The remaining keyword arguments. + """ + if response := kwargs.pop("response", None) or kwargs.pop("status", None): + self.response = int(response) + + if response_headers := ( + kwargs.pop("response_headers", None) or kwargs.pop("response headers", None) + ): + self.response_headers = parse_headers(response_headers) + + if response_content := ( + kwargs.pop("response_content", None) or kwargs.pop("response content", None) + ): + if self.response_body is None: + self.response_body = InteractionBody("") + self.response_body.mime_type = response_content + + if response_body := ( + kwargs.pop("response_body", None) or kwargs.pop("response body", None) + ): + orig_content_type = ( + self.response_body.mime_type if self.response_body else None + ) + self.response_body = InteractionBody(response_body) + self.response_body.mime_type = ( + self.response_body.mime_type or orig_content_type + ) + + if matching_rules := ( + kwargs.pop("response_matching_rules", None) + or kwargs.pop("response matching rules", None) + ): + self.response_matching_rules = parse_rules(matching_rules) + + if generators := ( + kwargs.pop("response_generators", None) + or kwargs.pop("response generators", None) + ): + self.response_generators = parse_rules(generators) + + return kwargs + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return "".format( + ", ".join(f"{k}={v!r}" for k, v in vars(self).items()), + ) + + def add_to_pact( # noqa: C901, PLR0912, PLR0915 + self, + pact: Pact, + name: str, + ) -> None: + """ + Add the interaction to the pact. + + This is a convenience method that allows the interaction definition to + be added to the pact, defining the "upon receiving ... with ... will + respond with ...". + + Args: + pact: + The pact being defined. + + name: + Name for this interaction. Must be unique for the pact. + """ + interaction = pact.upon_receiving(name, self.type) + if isinstance(interaction, HttpInteraction): + assert self.method, "Method must be defined" + assert self.path, "Path must be defined" + + logger.info("with_request(%r, %r)", self.method, self.path) + interaction.with_request(self.method, self.path) + + for state in self.states or []: + if state.parameters: + logger.info("given(%r, %r)", state.name, state.parameters) + interaction.given(state.name, state.parameters) + else: + logger.info("given(%r)", state.name) + interaction.given(state.name) + + if self.pending: + logger.info("set_pending(True)") + interaction.set_pending(pending=True) + + if self.text_comments: + for comment in self.text_comments: + logger.info("add_text_comment(%r)", comment) + interaction.add_text_comment(comment) + + for key, value in self.comments.items(): + logger.info("set_comment(%r, %r)", key, value) + interaction.set_comment(key, value) + + if self.test_name: + logger.info("test_name(%r)", self.test_name) + interaction.test_name(self.test_name) + + if self.query: + assert isinstance(interaction, HttpInteraction), ( + "Query parameters require an HTTP interaction" + ) + query = URL.build(query_string=self.query).query + logger.info("with_query_parameters(%r)", query.items()) + interaction.with_query_parameters(query.items()) + + if self.headers: + assert isinstance(interaction, HttpInteraction), ( + "Headers require an HTTP interaction" + ) + logger.info("with_headers(%r)", self.headers.items()) + interaction.with_headers(self.headers.items()) + + if self.body: + self.body.add_to_interaction(interaction) + + if self.matching_rules: + logger.info("with_matching_rules(%r)", self.matching_rules) + interaction.with_matching_rules(self.matching_rules) + + if self.generators: + logger.info("with_generators(%r)", self.generators) + interaction.with_generators(self.generators) + + if self.response: + assert isinstance(interaction, HttpInteraction), ( + "Response requires an HTTP interaction" + ) + logger.info("will_respond_with(%r)", self.response) + interaction.will_respond_with(self.response) + + if self.response_headers: + assert isinstance(interaction, HttpInteraction), ( + "Response headers require an HTTP interaction" + ) + logger.info("with_headers(%r)", self.response_headers) + interaction.with_headers(self.response_headers.items(), "Response") + + if self.response_body: + assert isinstance(interaction, HttpInteraction), ( + "Response body requires an HTTP interaction" + ) + self.response_body.add_to_interaction(interaction) + + if self.response_matching_rules: + logger.info("with_matching_rules(%r)", self.response_matching_rules) + interaction.with_matching_rules(self.response_matching_rules) + + if self.response_generators: + logger.info("with_generators(%r)", self.response_generators) + interaction.with_generators(self.response_generators) + + if self.metadata: + interaction.with_metadata(self.metadata) + + def matches_request(self, request: SimpleHTTPRequestHandler) -> bool: + """ + Check if a request matches the interaction. + + Args: + request: + The request to check. + + Returns: + Whether the request matches the interaction. + """ + if self.type == "HTTP": + logger.debug( + "Checking whether request '%s %s' matches '%s %s'", + request.command, + request.path, + self.method, + self.path, + ) + return ( + request.command == self.method + and request.path.split("?")[0] == self.path + ) + return False + + def handle_request(self, request: SimpleHTTPRequestHandler) -> None: + """ + Handle a HTTP request. + + Internally, we use Python's built-in [`http.server`][http.server] module + to handle the request. For each request, Pythhon instantiates a new + [`SimpleHTTPRequestHandler`][http.server.SimpleHTTPRequestHandler] + object with the request details and provider the interface to respond. + + Args: + request: + The request to handle. + """ + logger.debug("Handling request: %s %s", request.command, request.path) + if self.type == "HTTP": + self._handle_request_http(request) + elif self.type in ("Sync", "Async"): + msg = "Sync and Async interactions are handled by message relay." + raise ValueError(msg) + else: + msg = f"Unknown interaction type: {self.type}" + raise ValueError(msg) + + def _handle_request_http(self, request: SimpleHTTPRequestHandler) -> None: # noqa: C901, PLR0912 + """ + Add a HTTP interaction to a Flask app. + + This function works by defining a new function to handle the request and + produce the response. This function is then added to the Flask app as a + route. + + Args: + request: + The request to handle. + """ + assert isinstance(self.method, str), "Method must be a string" + assert isinstance(self.path, str), "Path must be a string" + + logger.info( + "Handling HTTP '%s %s' interaction", + self.method, + self.path, + ) + + # Check the request method + if request.command != self.method: + logger.error("Method mismatch: %s != %s", request.command, self.method) + request.send_error(405, "Method Not Allowed") + return + + # Check the request path + if request.path.split("?")[0] != self.path: + logger.error("Path mismatch: %s != %s", request.path, self.path) + request.send_error(404, "Not Found") + return + + # Check the query parameters + # + # We expect an exact match of the query parameters (unlike the headers) + if self.query: + logger.info("Checking request query parameters") + expected_query = URL.build(query_string=self.query).query + request_query = URL.build( + query_string=request.path.split("?")[1] if "?" in request.path else "" + ).query + if (expected_keys := set(expected_query.keys())) != ( + request_keys := set(request_query.keys()) + ): + logger.error( + "Query parameter mismatch: %s != %s", + request_keys, + expected_keys, + ) + request.send_error(400, "Bad Request") + return + + for k in expected_query: + if (request_vals := request_query.getall(k)) != ( + expected_vals := expected_query.getall(k) + ): + logger.error( + "Query parameter mismatch: %s != %s", + request_vals, + expected_vals, + ) + request.send_error(400, "Bad Request") + return + + # Check the headers + # + # We only check for the headers we expect from the interaction + # definition. It is very likely that the request will contain additional + # headers (e.g. `Host`, `User-Agent`, etc.) that we do not care about. + if self.headers: + logger.info("Checking request headers") + for k, v in self.headers.items(): + if (rv := request.headers.get(k)) != v: + logger.error("Header mismatch: %s != %s", rv, v) + request.send_error(400, "Bad Request") + return + + # Check the body + if self.body: + content_length = int(request.headers.get("Content-Length", 0)) + request_body = request.rfile.read(content_length) + if request_body != self.body.bytes: + request.send_error(400, "Bad Request") + return + + # Send the response + if not self.response: + warnings.warn( + "No response defined, defaulting to 200", + RuntimeWarning, + stacklevel=2, + ) + + request.send_response(self.response or 200) + for k, v in self.response_headers.items(): + request.send_header(k, v) + if self.response_body and self.response_body.mime_type: + request.send_header("Content-Type", self.response_body.mime_type) + request.end_headers() + if self.response_body and self.response_body.bytes: + request.wfile.write(self.response_body.bytes) + + def message_producer( + self, + name: str, + metadata: dict[str, Any] | None, + ) -> Message: + """ + Handle a message interaction. + + Args: + name: + The name of the message to produce. + + metadata: + Metadata for the message. + """ + logger.info("Handling message interaction") + logger.info(" -> Body: %r", name) + logger.info(" -> Metadata: %r", metadata) + assert self.type in ("Sync", "Async"), "Message interactions only" + + assert name == self.description, "Description mismatch" + + contents = self.body.bytes if self.body else None + return { + "contents": contents or b"", + "content_type": self.body.mime_type if self.body else None, + "metadata": self.metadata, + } diff --git a/tests/compatibility_suite/util/pact-broker.yml b/tests/compatibility_suite/util/pact-broker.yml new file mode 100644 index 000000000..d42d063d7 --- /dev/null +++ b/tests/compatibility_suite/util/pact-broker.yml @@ -0,0 +1,40 @@ +--- +services: + postgres: + image: postgres + ports: + - 5432:5432 + healthcheck: + test: psql postgres -U postgres --command 'SELECT 1' + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + + broker: + image: pactfoundation/pact-broker:latest + depends_on: + - postgres + ports: + - 9292:9292 + restart: always + environment: + # Basic auth credentials for the Broker + PACT_BROKER_ALLOW_PUBLIC_READ: 'true' + PACT_BROKER_BASIC_AUTH_USERNAME: pactbroker + PACT_BROKER_BASIC_AUTH_PASSWORD: pactbroker + # Database + PACT_BROKER_DATABASE_URL: postgres://postgres:postgres@postgres/postgres + # PACT_BROKER_DATABASE_URL: sqlite:////tmp/pact_broker.sqlite # Pending pact-foundation/pact-broker-docker#148 + + healthcheck: + test: + - CMD + - curl + - --silent + - --show-error + - --fail + - http://pactbroker:pactbroker@localhost:9292/diagnostic/status/heartbeat + interval: 1s + timeout: 2s + retries: 5 diff --git a/tests/compatibility_suite/util/provider.py b/tests/compatibility_suite/util/provider.py new file mode 100644 index 000000000..9c42c4940 --- /dev/null +++ b/tests/compatibility_suite/util/provider.py @@ -0,0 +1,1475 @@ +""" +Provider utilities for compatibility suite tests. + +This file has two main purposes. + +The first functionality provided by this module is the ability to start a +provider application with a set of interactions. Since this is done in a +subprocess, any configuration must be passed in through files. The process is +started with + +The second functionality provided by this module is to define some of the shared +steps for the compatibility suite tests. +""" + +from __future__ import annotations + +import copy +import inspect +import json +import logging +import re +import subprocess +import warnings +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from io import BytesIO +from threading import Thread +from typing import TYPE_CHECKING, Any, ClassVar, TypedDict +from unittest.mock import MagicMock + +import pytest +import requests +from multidict import CIMultiDict +from pytest_bdd import given, parsers, then, when +from yarl import URL + +import pact_cli +from pact import __version__ +from pact._server import MessageProducer +from pact._util import find_free_port +from pact.pact import Pact +from tests.compatibility_suite.util import ( + parse_headers, + parse_horizontal_table, +) +from tests.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, + InteractionState, +) + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + from types import TracebackType + + from typing_extensions import Self + + from pact.types import Message + from pact.verifier import Verifier + + +logger = logging.getLogger(__name__) + +VERIFIER_ERROR_MAP: dict[str, str] = { + "Response status did not match": "StatusMismatch", + "Headers had differences": "HeaderMismatch", + "Body had differences": "BodyMismatch", + "Metadata had differences": "MetadataMismatch", +} + + +def _next_version() -> Generator[str, None, None]: + """ + Get the next version for the consumer. + + This is used to generate a new version for the consumer application to use + when publishing the interactions to the Pact Broker. + + Returns: + The next version. + """ + version = 0 + while True: + yield str(version) + version += 1 + + +version_iter = _next_version() + + +class Provider: + """ + HTTP provider for the compatibility suite tests. + + As we are testing specific scenarios, this provider server is designed to + be easily customized to return specific responses for specific requests. + """ + + interactions: ClassVar[list[InteractionDefinition]] = [] + + def __init__( + self, + host: str = "localhost", + port: int | None = None, + ) -> None: + """ + Initialize the provider. + + Args: + host: + The host for the provider. + + port: + The port for the provider. If not provided, then a free port + will be found. + """ + self._host = host + self._port = port or find_free_port() + + self._interactions: list[InteractionDefinition] = [] + self.requests: list[ProviderRequestDict] | None = None + self._server: ProviderServer | None = None + self._thread: Thread | None = None + + @property + def host(self) -> str: + """ + Server host. + """ + return self._host + + @property + def port(self) -> int: + """ + Server port. + """ + return self._port + + @property + def url(self) -> URL: + """ + Server URL. + """ + return URL(f"http://{self.host}:{self.port}") + + def add_interaction(self, interaction: InteractionDefinition) -> None: + """ + Add an interaction to the provider. + + Args: + interaction: + The interaction to add. + """ + self._interactions.append(interaction) + + def __enter__(self) -> Self: + """ + Start the provider. + """ + logger.info( + "Starting provider on %s with %s interaction(s)", + self.url, + len(self._interactions), + ) + self._server = ProviderServer( + (self.host, self.port), + ProviderRequestHandler, + interactions=self._interactions, + ) + self._thread = Thread( + target=self._server.serve_forever, + name="Compatibility Suite Provider Server", + ) + self._thread.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """ + Exit the Provider context. + """ + if not self._thread or not self._server: + warnings.warn( + "Exiting server context despite server not being started.", + stacklevel=2, + ) + return + + self.requests = self._server.requests + self._server.shutdown() + self._thread.join() + + +class ProviderServer(ThreadingHTTPServer): + """ + Simple HTTP server for the provider. + """ + + def __init__( + self, + *args: Any, # noqa: ANN401 + interactions: list[InteractionDefinition], + **kwargs: Any, # noqa: ANN401 + ) -> None: + """ + Initialize the server. + + Args: + interactions: + The interactions to use for the server. + + *args: + Positional arguments to pass to the base `ThreadingHTTPServer` + class. + + **kwargs: + Keyword arguments to pass to the base `ThreadingHTTPServer` + class. + """ + self.interactions = interactions + self.requests: list[ProviderRequestDict] = [] + super().__init__(*args, **kwargs) + + +class ProviderRequestDict(TypedDict): + """ + Request dictionary for the provider server. + """ + + method: str | None + path: str | None + query: str | None + headers: CIMultiDict[str] | None + body: bytes | None + + +class ProviderRequestHandler(SimpleHTTPRequestHandler): + """ + Request handler for the provider server. + + This class is responsible for handling the requests made to the provider + server. It uses the standard library's + [`SimpleHTTPRequestHandler`][http.server.SimpleHTTPRequestHandler]. + """ + + if TYPE_CHECKING: + server: ProviderServer + + def version_string(self) -> str: + """ + Get the server version string. + + Returns: + The server version string. + """ + return f"Compatibility Suite Provider/{__version__}" + + def _record_request(self) -> None: + """ + Record the request. + + Parses the request and records it in the server's request list. + + The `rfile` attribute, being a file-like object, can only be read once. + This method reads the request body and then replaces the `rfile` + attribute with a new `BytesIO` object containing the request body. + """ + size = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(size) + request: ProviderRequestDict = { + "method": self.command, + "path": self.path, + "query": self.path.split("?", 1)[1] if "?" in self.path else None, + "headers": CIMultiDict(self.headers.items()), + "body": body, + } + self.server.requests.append(request) + self.rfile = BytesIO(body) + + def do_POST(self) -> None: + """ + Handle a POST request. + """ + logger.info("Handling %s %s", self.command, self.path) + self._record_request() + + for interaction in self.server.interactions: + if interaction.matches_request(self): + interaction.handle_request(self) + return + + logger.warning( + "No matching interaction found for %s %s", + self.command, + self.path, + ) + self.send_error(404, "Not Found") + + def do_GET(self) -> None: + """ + Handle a GET request. + """ + logger.info("Handling %s %s", self.command, self.path) + self._record_request() + + for interaction in self.server.interactions: + if interaction.matches_request(self): + interaction.handle_request(self) + return + + logger.warning( + "No matching interaction found for %s %s", + self.command, + self.path, + ) + self.send_error(404, "Not Found") + + +class PactBroker: + """ + Interface to the Pact Broker. + """ + + def __init__( + self, + broker_url: URL, + *, + username: str | None = None, + password: str | None = None, + provider: str = "provider", + consumer: str = "consumer", + ) -> None: + """ + Instantiate a new Pact Broker interface. + """ + self.url = broker_url + self.username = broker_url.user or username + self.password = broker_url.password or password + self.provider = provider + self.consumer = consumer + + if bin_path := pact_cli.BROKER_CLIENT_PATH: + self.broker_bin = bin_path + else: + msg = "pact-broker not found" + raise RuntimeError(msg) + + def reset(self) -> None: + """ + Reset the Pact Broker. + + This function will reset the Pact Broker by deleting all pacts and + verification results. + """ + requests.delete( + str( + self.url + / "integrations" + / "provider" + / self.provider + / "consumer" + / self.consumer + ), + timeout=2, + ) + + def publish(self, directory: Path | str, version: str | None = None) -> None: + """ + Publish the interactions to the Pact Broker. + + Args: + directory: + The directory containing the pact files. + + version: + The version of the consumer application. + """ + cmd = [ + self.broker_bin, + "publish", + str(directory), + "--broker-base-url", + str(self.url.with_user(None).with_password(None)), + ] + if self.username: + cmd.extend(["--broker-username", self.username]) + if self.password: + cmd.extend(["--broker-password", self.password]) + + cmd.extend(["--consumer-app-version", version or next(version_iter)]) + + subprocess.run( # noqa: S603 + cmd, + encoding="utf-8", + check=True, + ) + + def interaction_id(self, num: int) -> str: + """ + Find the interaction ID for the given interaction. + + This function is used to find the Pact Broker interaction ID for the given + interaction. It does this by looking for the interaction with the + description `f"interaction {num}"`. + + Args: + num: + The ID of the interaction. + """ + response = requests.get( + str( + self.url + / "pacts" + / "provider" + / self.provider + / "consumer" + / self.consumer + / "latest" + ), + timeout=2, + ) + response.raise_for_status() + for interaction in response.json()["interactions"]: + if interaction["description"] == f"interaction {num}": + return interaction["_id"] + msg = f"Interaction {num} not found" + raise ValueError(msg) + + def verification_results(self, num: int) -> requests.Response: + """ + Fetch the verification results for the given interaction. + + Args: + num: + The ID of the interaction. + """ + interaction_id = self.interaction_id(num) + response = requests.get( + str( + self.url + / "pacts" + / "provider" + / self.provider + / "consumer" + / self.consumer + / "latest" + / "verification-results" + / interaction_id + ), + timeout=2, + ) + response.raise_for_status() + return response + + def latest_verification_results(self) -> requests.Response | None: + """ + Fetch the latest verification results for the provider. + + If there are no verification results, then this function will return + `None`. + """ + response = requests.get( + str( + self.url + / "pacts" + / "provider" + / self.provider + / "consumer" + / self.consumer + / "latest" + ), + timeout=2, + ) + response.raise_for_status() + links = response.json()["_links"] + response = requests.get( + links["pb:latest-verification-results"]["href"], timeout=2 + ) + if response.status_code == 404: + return None + response.raise_for_status() + return response + + +################################################################################ +## Given +################################################################################ + + +def a_provider_is_started_that_returns_the_responses_from_interactions( + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a provider is started that returns the responses? " + r'from interactions? "?(?P[0-9, ]+)"?', + ), + converters={"interactions": lambda x: [int(i) for i in x.split(",") if i]}, + target_fixture="provider", + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + interactions: list[int], + ) -> Generator[Provider, None, None]: + """ + Start a provider that returns the responses from the given interactions. + """ + logger.info("Starting provider for interactions %s", interactions) + + provider = Provider() + for i in interactions: + logger.info("Interaction %d: %s", i, interaction_definitions[i]) + provider.add_interaction(interaction_definitions[i]) + + with provider: + yield provider + + +def a_provider_is_started_that_returns_the_responses_from_interactions_with_changes( + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a provider is started that returns the responses?" + r' from interactions? "?(?P[0-9, ]+)"?' + r" with the following changes:", + re.DOTALL, + ), + converters={"ids": lambda x: [int(i) for i in x.split(",") if i]}, + target_fixture="provider", + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + ids: list[int], + datatable: list[list[str]], + ) -> Generator[Provider, None, None]: + """ + Start a provider that returns the responses from the given interactions. + """ + logger.info("Starting provider for modified interactions %s", ids) + changes = parse_horizontal_table(datatable) + + assert len(changes) == 1, "Only one set of changes is supported" + interactions: list[InteractionDefinition] = [] + for id_ in ids: + interaction = copy.deepcopy(interaction_definitions[id_]) + interaction.update(**changes[0]) # type: ignore[arg-type] + interactions.append(interaction) + logger.info( + "Updated interaction %d: %s", + id_, + interaction, + ) + + provider = Provider() + for interaction in interactions: + provider.add_interaction(interaction) + with provider: + yield provider + + +def a_provider_is_started_that_can_generate_the_message( + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a provider is started" + r' that can generate the "(?P[^"]+)" message' + r' with "(?P.+)"$' + ), + target_fixture="provider", + stacklevel=stacklevel + 1, + ) + def _( + verifier: Verifier, + name: str, + body: str, + ) -> None: + logger.info("Starting provider for message %s", name) + interaction = InteractionDefinition( + type="Async", + description=name, + body=body.replace(r"\"", '"'), + ) + # If there's no content type, then it is a `text/plain` message + if interaction.body and not interaction.body.mime_type: + interaction.body.mime_type = "text/plain" + + # The following is a hack to allow for multiple message interactions to + # be defined. Typically, the end user would know all messages to be + # produced; however, we don't have this luxury in this context. + if isinstance(verifier._message_producer, MessageProducer): # noqa: SLF001 + original_handler = verifier._message_producer._handler # noqa: SLF001 + + def handler(*args: Any, **kwargs: Any) -> Message: # noqa: ANN401 + try: + return original_handler(*args, **kwargs) + except AssertionError: + return interaction.message_producer(*args, **kwargs) + + verifier.message_handler(handler) + + else: + verifier.message_handler(interaction.message_producer) + + +def a_pact_file_for_interaction_is_to_be_verified( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a Pact file for interaction (?P\d+) is to be verified" + r"(?P(, but is marked pending)?)", + ), + converters={"interaction": int, "pending": lambda x: x != ""}, + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + verifier: Verifier, + interaction: int, + pending: bool, # noqa: FBT001 + tmp_path: Path, + ) -> None: + """ + Verify the Pact file for the given interaction. + """ + logger.info( + "Adding interaction %d to be verified: %s", + interaction, + interaction_definitions[interaction], + ) + + defn = interaction_definitions[interaction] + defn.pending = pending + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, f"interaction {interaction}") + (tmp_path / "pacts").mkdir(exist_ok=True, parents=True) + pact.write_file(tmp_path / "pacts") + + with (tmp_path / "pacts" / "consumer-provider.json").open( + "r", + encoding="utf-8", + ) as f: + for line in f: + logger.info("Pact file: %s", line.rstrip()) + + verifier.add_source(tmp_path / "pacts") + + +def a_pact_file_for_message_is_to_be_verified( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r'a Pact file for "(?P[^"]+)":"(?P[^"]+)" is to be verified' + r"(?P(, but is marked pending)?)", + ), + converters={"pending": lambda x: x != ""}, + stacklevel=stacklevel + 1, + ) + def _( + verifier: Verifier, + tmp_path: Path, + name: str, + fixture: str, + pending: bool, # noqa: FBT001 + ) -> None: + defn = InteractionDefinition( + type="Async", + description=name, + body=fixture, + ) + defn.pending = pending + logger.info("Adding message interaction: %s", defn) + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, name) + (tmp_path / "pacts").mkdir(exist_ok=True, parents=True) + pact.write_file(tmp_path / "pacts") + + with (tmp_path / "pacts" / "consumer-provider.json").open() as f: + logger.info("Pact file contents: %s", f.read()) + + verifier.add_source(tmp_path / "pacts") + + +def a_pact_file_for_interaction_is_to_be_verified_with_comments( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a Pact file for interaction (?P\d+) is to be verified" + r" with the following comments:", + re.DOTALL, + ), + converters={"interaction": int}, + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + verifier: Verifier, + interaction: int, + datatable: list[list[str]], + tmp_path: Path, + ) -> None: + """ + Verify the Pact file for the given interaction. + """ + logger.info( + "Adding interaction %d to be verified: %s", + interaction, + interaction_definitions[interaction], + ) + comments = parse_horizontal_table(datatable) + defn = interaction_definitions[interaction] + for comment in comments: + if comment["type"] == "text": + defn.text_comments.append(comment["comment"]) + elif comment["type"] == "testname": + defn.test_name = comment["comment"] + else: + defn.comments[comment["type"]] = comment["comment"] + logger.info("Updated interaction %d: %s", interaction, defn) + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, f"interaction {interaction}") + (tmp_path / "pacts").mkdir(exist_ok=True, parents=True) + pact.write_file(tmp_path / "pacts") + + with (tmp_path / "pacts" / "consumer-provider.json").open( + "r", + encoding="utf-8", + ) as f: + for line in f: + logger.info("Pact file: %s", line.rstrip()) + + verifier.add_source(tmp_path / "pacts") + + +def a_pact_file_for_message_is_to_be_verified_with_comments( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r'a Pact file for "(?P[^"]+)":"(?P[^"]+)" is to be verified' + r" with the following comments:", + re.DOTALL, + ), + stacklevel=stacklevel + 1, + ) + def _( + verifier: Verifier, + tmp_path: Path, + name: str, + fixture: str, + datatable: list[list[str]], + ) -> None: + logger.info("Adding message interaction %s with comments", name) + defn = InteractionDefinition( + type="Async", + description=name, + body=fixture, + ) + comments = parse_horizontal_table(datatable) + for comment in comments: + if comment["type"] == "text": + defn.text_comments.append(comment["comment"]) + elif comment["type"] == "testname": + defn.test_name = comment["comment"] + else: + defn.comments[comment["type"]] = comment["comment"] + logger.info("Updated interaction: %s", defn) + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, name) + (tmp_path / "pacts").mkdir(exist_ok=True, parents=True) + pact.write_file(tmp_path / "pacts") + + with (tmp_path / "pacts" / "consumer-provider.json").open() as f: + logger.info("Pact file contents: %s", f.read()) + + verifier.add_source(tmp_path / "pacts") + + +def a_pact_file_for_interaction_is_to_be_verified_from_a_pact_broker( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a Pact file for interaction (?P\d+)" + r" is to be verified from a Pact broker", + ), + converters={"interaction": int}, + target_fixture="pact_broker", + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + broker_url: URL, + verifier: Verifier, + interaction: int, + tmp_path: Path, + ) -> Generator[PactBroker, None, None]: + """ + Verify the Pact file for the given interaction from a Pact broker. + """ + logger.info( + "Adding interaction %d to be verified from a Pact broker", interaction + ) + + defn = interaction_definitions[interaction] + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, f"interaction {interaction}") + + pacts_dir = tmp_path / "pacts" + pacts_dir.mkdir(exist_ok=True, parents=True) + pact.write_file(pacts_dir) + + pact_broker = PactBroker(broker_url) + pact_broker.publish(pacts_dir) + verifier.broker_source(pact_broker.url) + yield pact_broker + + +def publishing_of_verification_results_is_enabled(stacklevel: int = 1) -> None: + @given("publishing of verification results is enabled", stacklevel=stacklevel + 1) + def _(verifier: Verifier) -> None: + """ + Enable publishing of verification results. + """ + logger.info("Publishing verification results") + + verifier.set_publish_options( + "0.0.0", + ) + + +def a_provider_state_callback_is_configured( + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a provider state callback is configured" + r"(?P(, but will return a failure)?)", + ), + target_fixture="provider_callback", + converters={"failure": lambda x: x != ""}, + stacklevel=stacklevel + 1, + ) + def _( + verifier: Verifier, + failure: bool, # noqa: FBT001 + ) -> MagicMock: + """ + Configure a provider state callback. + """ + logger.info("Configuring provider state callback") + + def _callback( + state: str, + action: str, + parameters: dict[str, Any] | None, + ) -> None: + pass + + provider_callback = MagicMock(return_value=None, spec=_callback) + provider_callback.__signature__ = inspect.signature(_callback) + if failure: + provider_callback.side_effect = RuntimeError("Provider state change failed") + + verifier.state_handler( + provider_callback, + teardown=True, + ) + return provider_callback + + +def a_pact_file_for_interaction_is_to_be_verified_with_a_provider_state_defined( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a Pact file for interaction (?P\d+) is to be verified" + r' with a provider state "(?P[^"]+)" defined', + ), + converters={"interaction": int}, + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + verifier: Verifier, + interaction: int, + state: str, + tmp_path: Path, + ) -> None: + """ + Verify the Pact file for the given interaction with a provider state defined. + """ + logger.info( + "Adding interaction %d to be verified with provider state %s", + interaction, + state, + ) + + defn = interaction_definitions[interaction] + defn.states = [InteractionState(state)] + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, f"interaction {interaction}") + (tmp_path / "pacts").mkdir(exist_ok=True, parents=True) + pact.write_file(tmp_path / "pacts") + + verifier.add_source(tmp_path / "pacts") + + with (tmp_path / "provider_states").open("w") as f: + logger.info("Writing provider state to %s", tmp_path / "provider_states") + json.dump([s.as_dict() for s in defn.states], f) + + +def a_pact_file_for_interaction_is_to_be_verified_with_a_provider_states_defined( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a Pact file for interaction (?P\d+) is to be verified" + r" with the following provider states defined:", + re.DOTALL, + ), + converters={"interaction": int}, + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + verifier: Verifier, + interaction: int, + datatable: list[list[str]], + tmp_path: Path, + ) -> None: + """ + Verify the Pact file for the given interaction with provider states defined. + """ + states = parse_horizontal_table(datatable) + logger.info( + "Adding interaction %d to be verified with provider states %s", + interaction, + states, + ) + + defn = interaction_definitions[interaction] + defn.states = [ + InteractionState(s["State Name"], s.get("Parameters", None)) for s in states + ] + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, f"interaction {interaction}") + (tmp_path / "pacts").mkdir(exist_ok=True, parents=True) + pact.write_file(tmp_path / "pacts") + + verifier.add_source(tmp_path / "pacts") + + with (tmp_path / "provider_states").open("w") as f: + logger.info("Writing provider state to %s", tmp_path / "provider_states") + json.dump([s.as_dict() for s in defn.states], f) + + +def a_request_filter_is_configured_to_make_the_following_changes( + stacklevel: int = 1, +) -> None: + @given( + parsers.parse("a request filter is configured to make the following changes:"), + stacklevel=stacklevel + 1, + ) + def _( + datatable: list[list[str]], + verifier: Verifier, + ) -> None: + """ + Configure a request filter to make the given changes. + """ + logger.info("Configuring request filter") + + changes = parse_horizontal_table(datatable) + if "headers" in changes[0]: + verifier.add_custom_headers(parse_headers(changes[0]["headers"]).items()) + else: + msg = "Unsupported filter type" + raise RuntimeError(msg) + + +################################################################################ +## When +################################################################################ + + +def the_verification_is_run( + stacklevel: int = 1, +) -> None: + @when( + "the verification is run", + target_fixture="verifier_result", + stacklevel=stacklevel + 1, + ) + def _( + verifier: Verifier, + provider: Provider | None, + ) -> tuple[Verifier, Exception | None]: + """ + Run the verification. + """ + logger.info("Running verification on %r", verifier) + + if provider: + verifier.add_transport(url=provider.url) + + try: + verifier.verify() + except Exception as e: # noqa: BLE001 + return verifier, e + return verifier, None + + +################################################################################ +## Then +################################################################################ + + +def the_verification_will_be_successful( + stacklevel: int = 1, +) -> None: + @then( + parsers.re(r"the verification will(?P( NOT)?) be successful"), + converters={"negated": lambda x: x == " NOT"}, + stacklevel=stacklevel + 1, + ) + def _( + verifier_result: tuple[Verifier, Exception | None], + negated: bool, # noqa: FBT001 + ) -> None: + """ + Check that the verification was successful. + """ + logger.info("Checking verification result") + logger.info("Verifier result: %s", verifier_result) + + if negated: + assert verifier_result[1] is not None + else: + assert verifier_result[1] is None + + +def the_verification_results_will_contain_a_error( + stacklevel: int = 1, +) -> None: + @then( + parsers.re(r'the verification results will contain a "(?P[^"]+)" error'), + stacklevel=stacklevel + 1, + ) + def _(verifier_result: tuple[Verifier, Exception | None], error: str) -> None: + """ + Check that the verification results contain the given error. + """ + logger.info("Checking that verification results contain error %s", error) + + verifier = verifier_result[0] + logger.info("Verification results: %s", json.dumps(verifier.results, indent=2)) + + mismatch_type = VERIFIER_ERROR_MAP.get(error) + if not mismatch_type: + if error == "State change request failed": + assert "One or more of the setup state change handlers has failed" in [ + error["mismatch"]["message"] for error in verifier.results["errors"] + ] + return + msg = f"Unknown error type: {error}" + raise ValueError(msg) + + mismatch_types = [ + mismatch["type"] + for error in verifier.results["errors"] + for mismatch in error["mismatch"]["mismatches"] + ] + assert mismatch_type in mismatch_types + if len(mismatch_types) > 1: + warnings.warn( + f"Multiple mismatch types found: {mismatch_types}", + stacklevel=1, + ) + for verifier_error in verifier.results["errors"]: + for mismatch in verifier_error["mismatch"]["mismatches"]: + warnings.warn(f"Mismatch: {mismatch}", stacklevel=1) + + +def a_verification_result_will_not_be_published_back( + stacklevel: int = 1, +) -> None: + @then( + parsers.re(r"a verification result will NOT be published back"), + stacklevel=stacklevel + 1, + ) + def _(pact_broker: PactBroker) -> None: + """ + Check that the verification result was published back to the Pact broker. + """ + logger.info("Checking that verification result was not published back") + + response = pact_broker.latest_verification_results() + if response: + with pytest.raises(requests.HTTPError, match="404 Client Error"): + response.raise_for_status() + + +def a_successful_verification_result_will_be_published_back( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + "a successful verification result " + "will be published back " + r"for interaction \{(?P\d+)\}", + ), + converters={"interaction": int}, + stacklevel=stacklevel + 1, + ) + def _( + pact_broker: PactBroker, + interaction: int, + ) -> None: + """ + Check that the verification result was published back to the Pact broker. + """ + logger.info( + "Checking that verification result was published back for interaction %d", + interaction, + ) + + interaction_id = pact_broker.interaction_id(interaction) + response = pact_broker.latest_verification_results() + assert response is not None + assert response.ok + data: dict[str, Any] = response.json() + assert data["success"] + + for test_result in data["testResults"]: + if test_result["interactionId"] == interaction_id: + assert test_result["success"] + break + else: + msg = f"Interaction {interaction} not found in verification results" + raise ValueError(msg) + + +def a_failed_verification_result_will_be_published_back( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + "a failed verification result " + "will be published back " + r"for the interaction \{(?P\d+)\}", + ), + converters={"interaction": int}, + stacklevel=stacklevel + 1, + ) + def _( + pact_broker: PactBroker, + interaction: int, + ) -> None: + """ + Check that the verification result was published back to the Pact broker. + """ + logger.info( + "Checking that failed verification result" + " was published back for interaction %d", + interaction, + ) + + interaction_id = pact_broker.interaction_id(interaction) + response = pact_broker.latest_verification_results() + assert response is not None + assert response.ok + data: dict[str, Any] = response.json() + assert not data["success"] + + for test_result in data["testResults"]: + if test_result["interactionId"] == interaction_id: + assert not test_result["success"] + break + else: + msg = f"Interaction {interaction} not found in verification results" + raise ValueError(msg) + + +def the_provider_state_callback_will_be_called_before_the_verification_is_run( + stacklevel: int = 1, +) -> None: + @then( + "the provider state callback will be called before the verification is run", + stacklevel=stacklevel + 1, + ) + def _() -> None: + """ + Check that the provider state callback was called before the verification. + """ + logger.info("Checking provider state callback was called before verification") + + +def the_provider_state_callback_will_receive_a_setup_call( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"the provider state callback" + r" will receive a (?Psetup|teardown) call" + r' (with )?"(?P[^"]*)" as the provider state parameter', + ), + stacklevel=stacklevel + 1, + ) + def _( + provider_callback: MagicMock, + action: str, + state: str, + ) -> None: + """ + Check that the provider state callback received a setup call. + """ + logger.info("Checking provider state callback received a %s call", action) + logger.debug("Calls: %s", provider_callback.call_args_list) + provider_callback.assert_called() + for calls in provider_callback.call_args_list: + if ( + calls.kwargs.get("state") == state + and calls.kwargs.get("action") == action + ): + return + + msg = f"No {action} call found" + raise AssertionError(msg) + + +def the_provider_state_callback_will_receive_a_setup_call_with_parameters( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"the provider state callback" + r" will receive a (?Psetup|teardown) call" + r' (with )?"(?P[^"]*)"' + r" and the following parameters:", + re.DOTALL, + ), + stacklevel=stacklevel + 1, + ) + def _( + provider_callback: MagicMock, + action: str, + state: str, + datatable: list[list[str]], + ) -> None: + """ + Check that the provider state callback received a setup call. + """ + logger.info("Checking provider state callback received a %s call", action) + parameters = parse_horizontal_table(datatable) + params: dict[str, Any] = parameters[0] + # Values are JSON values, so parse them + for key, value in params.items(): + params[key] = json.loads(value) + + provider_callback.assert_called() + for calls in provider_callback.call_args_list: + if ( + calls.kwargs.get("state") == state + and calls.kwargs.get("action") == action + and calls.kwargs.get("parameters") == params + ): + return + msg = f"No {action} call found" + raise AssertionError(msg) + + +def the_provider_state_callback_will_not_receive_a_setup_call( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"the provider state callback will " + r"NOT receive a (?Psetup|teardown) call" + ), + stacklevel=stacklevel + 1, + ) + def _( + tmp_path: Path, + action: str, + ) -> None: + """ + Check that the provider state callback did not receive a setup call. + """ + for file in tmp_path.glob("callback.*.json"): + with file.open("r") as f: + data: dict[str, Any] = json.load(f) + logger.info("Checking callback data: %s", data) + if ( + "action" in data["query_params"] + and data["query_params"]["action"] == action + ): + msg = f"Unexpected {action} call found" + raise AssertionError(msg) + + +def the_provider_state_callback_will_be_called_after_the_verification_is_run( + stacklevel: int = 1, +) -> None: + @then( + "the provider state callback will be called after the verification is run", + stacklevel=stacklevel + 1, + ) + def _() -> None: + """ + Check that the provider state callback was called after the verification. + """ + + +def a_warning_will_be_displayed_that_there_was_no_callback_configured( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"a warning will be displayed" + r" that there was no provider state callback configured" + r' for provider state "(?P[^"]*)"', + ), + stacklevel=stacklevel + 1, + ) + def _( + state: str, + ) -> None: + """ + Check that a warning was displayed that there was no callback configured. + """ + logger.info("Checking for warning about missing provider state callback") + assert state + + +def the_request_to_the_provider_will_contain_the_header( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r'the request to the provider will contain the header "(?P
[^"]+)"', + ), + converters={"header": lambda x: parse_headers(f"'{x}'")}, + stacklevel=stacklevel + 1, + ) + def _( + provider: Provider, + header: dict[str, str], + ) -> None: + """ + Check that the request to the provider contained the given header. + """ + logger.info("Checking for header %r in provider requests", header) + provider.__exit__(None, None, None) + assert provider.requests + assert len(provider.requests) == 1 + request = provider.requests[0] + assert request["headers"] + + for key, value in header.items(): + assert key in request["headers"] + assert request["headers"][key] == value + + +def there_will_be_a_pending_error( + stacklevel: int = 1, +) -> None: + @then( + parsers.re(r'there will be a pending "(?P[^"]+)" error'), + stacklevel=stacklevel + 1, + ) + def _( + error: str, + verifier_result: tuple[Verifier, Exception | None], + ) -> None: + """ + There will be a pending error. + """ + logger.info("Checking for pending error") + verifier, err = verifier_result + + if error == "Body had differences": + mismatch = "BodyMismatch" + else: + msg = f"Unknown error type: {error}" + raise ValueError(msg) + + assert err is None + assert "pendingErrors" in verifier.results + for verifier_error in verifier.results["pendingErrors"]: + mismatches = [m["type"] for m in verifier_error["mismatch"]["mismatches"]] + if mismatch in mismatches: + if len(mismatches) > 1: + warnings.warn( + f"Multiple mismatch types found: {mismatches}", + stacklevel=2, + ) + break + else: + msg = "Pending error not found" + raise AssertionError(msg) + + +def the_comment_will_have_been_printed_to_the_console(stacklevel: int = 1) -> None: + @then( + parsers.re( + r'the comment "(?P[^"]+)" will have been printed to the console' + ), + stacklevel=stacklevel + 1, + ) + def _( + comment: str, + verifier_result: tuple[Verifier, Exception | None], + ) -> None: + """ + Check that the given comment was printed to the console. + """ + verifier, err = verifier_result + logger.info("Checking for comment %r in verifier output", comment) + logger.info("Verifier output: %s", verifier.output(strip_ansi=True)) + assert err is None + assert comment in verifier.output(strip_ansi=True) + + +def the_name_of_the_test_will_be_displayed_as_the_original_test_name( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r'the "(?P[^"]+)" will displayed as the original test name' + ), + stacklevel=stacklevel + 1, + ) + def _( + test_name: str, + verifier_result: tuple[Verifier, Exception | None], + ) -> None: + """ + Check that the given test name was displayed as the original test name. + """ + verifier, err = verifier_result + logger.info("Checking for test name %r in verifier output", test_name) + logger.info("Verifier output: %s", verifier.output(strip_ansi=True)) + assert err is None + assert test_name in verifier.output(strip_ansi=True) diff --git a/tests/conftest.py b/tests/conftest.py index 20b94aaa6..f41ae4845 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,55 @@ -# conftest +""" +PyTest configuration file for the v3 API tests. + +This file is loaded automatically by PyTest when running the tests in this +directory. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +import pact_ffi + + +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + pact_ffi.log_to_stderr("INFO") + + +@pytest.fixture +def temp_assets(tmp_path: Path) -> Path: + """ + Create a temporary directory with some minimal files for testing. + + The directory is populated with a few minimal files: + + - `test.py`: A minimal hello-world Python script. + - `test.txt`: A minimal text file. + - `test.json`: A minimal JSON file. + - `test.png`: A minimal PNG image. + """ + tmp_path = Path(tempfile.mkdtemp()) + with (tmp_path / "test.py").open("w") as f: + f.write('print("Hello, world!")') + with (tmp_path / "test.txt").open("w") as f: + f.write("Hello, world!") + with (tmp_path / "test.json").open("w") as f: + json.dump({"hello": "world"}, f) + with (tmp_path / "test.png").open("wb") as f: + f.write( + b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4" + b"\x89\x00\x00\x00\x0a\x49\x44\x41\x54\x78\x9c\x63\x00\x01\x00\x00" + b"\x05\x00\x01\x0d\x0a\x2d\xb4\x00\x00\x00\x00\x49\x45\x4e\x44\xae" + b"\x42\x60\x82", + ) + + return tmp_path diff --git a/tests/interaction/test_async_message_interaction.py b/tests/interaction/test_async_message_interaction.py new file mode 100644 index 000000000..1078b17f4 --- /dev/null +++ b/tests/interaction/test_async_message_interaction.py @@ -0,0 +1,57 @@ +""" +Pact Async Message Interaction unit tests. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING + +import pytest + +from pact import Pact + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def pact() -> Pact: + """ + Fixture for a Pact instance. + """ + return Pact("consumer", "provider") + + +def test_str(pact: Pact) -> None: + interaction = pact.upon_receiving("a basic request", "Async") + assert str(interaction) == "AsyncMessageInteraction(a basic request)" + + +def test_repr(pact: Pact) -> None: + interaction = pact.upon_receiving("a basic request", "Async") + assert ( + re.match( + r"^AsyncMessageInteraction\(InteractionHandle\(\d+\)\)$", + repr(interaction), + ) + is not None + ) + + +def test_add_external_reference(pact: Pact, tmp_path: Path) -> None: + ( + pact + .upon_receiving("an async message with an external reference", "Async") + .add_external_reference( + "GitHub", + "PR-456", + "https://github.com/org/repo/pull/456", + ) + .with_body({"event": "user.created"}) + ) + pact.write_file(tmp_path) + data = json.load((tmp_path / "consumer-provider.json").open()) + references = data["interactions"][0]["comments"]["references"] + assert references["GitHub"]["PR-456"] == "https://github.com/org/repo/pull/456" diff --git a/tests/interaction/test_http_interaction.py b/tests/interaction/test_http_interaction.py new file mode 100644 index 000000000..6e7445a44 --- /dev/null +++ b/tests/interaction/test_http_interaction.py @@ -0,0 +1,669 @@ +""" +Pact Http Interaction unit tests. +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import TYPE_CHECKING + +import aiohttp +import pytest + +from pact import Pact, match +from pact.error import RequestMismatch, RequestNotFound +from pact.pact import MismatchesError + +if TYPE_CHECKING: + from pathlib import Path + +ALL_HTTP_METHODS = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "TRACE", +] + + +@pytest.fixture +def pact() -> Pact: + """ + Fixture for a Pact instance. + """ + return Pact("consumer", "provider") + + +def test_str(pact: Pact) -> None: + interaction = pact.upon_receiving("a basic request") + assert str(interaction) == "HttpInteraction(a basic request)" + + +def test_repr(pact: Pact) -> None: + interaction = pact.upon_receiving("a basic request") + assert ( + re.match(r"^HttpInteraction\(InteractionHandle\(\d+\)\)$", repr(interaction)) + is not None + ) + + +@pytest.mark.parametrize( + "method", + ALL_HTTP_METHODS, +) +@pytest.mark.asyncio +async def test_basic_request_method(pact: Pact, method: str) -> None: + ( + pact + .upon_receiving(f"a basic {method} request") + .with_request(method, "/") + .will_respond_with(200) + ) + with pact.serve(raises=False) as srv: + async with aiohttp.ClientSession(srv.url) as session: + for m in ALL_HTTP_METHODS: + async with session.request(m, "/") as resp: + assert resp.status == (200 if m == method else 500) + + # As we are making unexpected requests, we should have mismatches + for mismatch in srv.mismatches: + assert isinstance(mismatch, RequestNotFound) + + +@pytest.mark.parametrize( + "status", + list(range(200, 600, 13)), +) +@pytest.mark.asyncio +async def test_basic_response_status(pact: Pact, status: int) -> None: + ( + pact + .upon_receiving(f"a basic request producing status {status}") + .with_request("GET", "/") + .will_respond_with(status) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request("GET", "/") as resp: + assert resp.status == status + + +@pytest.mark.parametrize( + "headers", + [ + [("X-Test", "true")], + [("X-Foo", "true"), ("X-Bar", "true")], + [("X-Test", "1"), ("X-Test", "2")], + ], +) +@pytest.mark.asyncio +async def test_with_header_request( + pact: Pact, + headers: list[tuple[str, str]], +) -> None: + ( + pact + .upon_receiving("a basic request with a header") + .with_request("GET", "/") + .with_headers(headers) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request("GET", "/", headers=headers) as resp: + assert resp.status == 200 + + +@pytest.mark.parametrize( + "headers", + [ + [("X-Test", "true")], + [("X-Foo", "true"), ("X-Bar", "true")], + [("X-Test", "1"), ("X-Test", "2")], + ], +) +@pytest.mark.asyncio +async def test_with_header_response( + pact: Pact, + headers: list[tuple[str, str]], +) -> None: + ( + pact + .upon_receiving("a basic request with a header") + .with_request("GET", "/") + .will_respond_with(200) + .with_headers(headers) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request("GET", "/") as resp: + assert resp.status == 200 + response_headers = [(h.lower(), v) for h, v in resp.headers.items()] + for header, value in headers: + assert (header.lower(), value) in response_headers + + +@pytest.mark.asyncio +async def test_with_header_dict(pact: Pact) -> None: + ( + pact + .upon_receiving("a basic request with a headers from a dict") + .with_request("GET", "/") + .with_headers({"X-Test": "true", "X-Foo": "bar"}) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + headers={"X-Test": "true", "X-Foo": "bar"}, + ) as resp: + assert resp.status == 200 + + +@pytest.mark.parametrize( + "headers", + [ + [("X-Test", "true")], + [("X-Foo", "true"), ("X-Bar", "true")], + ], +) +@pytest.mark.asyncio +async def test_set_header_request( + pact: Pact, + headers: list[tuple[str, str]], +) -> None: + ( + pact + .upon_receiving("a basic request with a header") + .with_request("GET", "/") + .set_headers(headers) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request("GET", "/", headers=headers) as resp: + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_set_header_request_repeat( + pact: Pact, +) -> None: + headers = [("X-Test", "1"), ("X-Test", "2")] + ( + pact + .upon_receiving("a basic request with a header") + .with_request("GET", "/") + # As set_headers makes no additional processing, the last header will be + # the one that is used. + .set_headers(headers) + .will_respond_with(200) + ) + with pact.serve(raises=False) as srv: + async with ( + aiohttp.ClientSession(srv.url) as session, + session.request( + "GET", + "/", + headers=headers, + ) as resp, + ): + assert resp.status == 500 + + assert len(srv.mismatches) == 1 + assert isinstance(srv.mismatches[0], RequestMismatch) + + +@pytest.mark.parametrize( + "headers", + [ + [("X-Test", "true")], + [("X-Foo", "true"), ("X-Bar", "true")], + ], +) +@pytest.mark.asyncio +async def test_set_header_response( + pact: Pact, + headers: list[tuple[str, str]], +) -> None: + ( + pact + .upon_receiving("a basic request with a header") + .with_request("GET", "/") + .will_respond_with(200) + .set_headers(headers) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request("GET", "/") as resp: + assert resp.status == 200 + response_headers = [(h.lower(), v) for h, v in resp.headers.items()] + for header, value in headers: + assert (header.lower(), value) in response_headers + + +@pytest.mark.asyncio +async def test_set_header_response_repeat( + pact: Pact, +) -> None: + headers = [("X-Test", "1"), ("X-Test", "2")] + ( + pact + .upon_receiving("a basic request with a header") + .with_request("GET", "/") + .will_respond_with(200) + # As set_headers makes no additional processing, the last header will be + # the one that is used. + .set_headers(headers) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request("GET", "/", headers=headers) as resp: + assert resp.status == 200 + response_headers = [(h.lower(), v) for h, v in resp.headers.items()] + assert ("x-test", "2") in response_headers + assert ("x-test", "1") not in response_headers + + +@pytest.mark.asyncio +async def test_set_header_dict(pact: Pact) -> None: + ( + pact + .upon_receiving("a basic request with a headers from a dict") + .with_request("GET", "/") + .set_headers({"X-Test": "true", "X-Foo": "bar"}) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + headers={"X-Test": "true", "X-Foo": "bar"}, + ) as resp: + assert resp.status == 200 + + +@pytest.mark.parametrize( + "query", + [ + [("test", "true")], + [("foo", "true"), ("bar", "true")], + [("test", "1"), ("test", "2")], + ], +) +@pytest.mark.asyncio +async def test_with_query_parameter_request( + pact: Pact, + query: list[tuple[str, str]], +) -> None: + ( + pact + .upon_receiving("a basic request with a query parameter") + .with_request("GET", "/") + .with_query_parameters(query) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + url = srv.url.with_query(query) + async with session.request("GET", url.path_qs) as resp: + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_with_query_parameter_with_matcher( + pact: Pact, +) -> None: + ( + pact + .upon_receiving("a basic request with a query parameter") + .with_request("GET", "/") + .with_query_parameter("test", match.string("true")) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + url = srv.url.with_query([("test", "true")]) + async with session.request("GET", url.path_qs) as resp: + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_with_query_parameter_dict(pact: Pact) -> None: + ( + pact + .upon_receiving("a basic request with a query parameter from a dict") + .with_request("GET", "/") + .with_query_parameters({"test": "true", "foo": "bar"}) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + url = srv.url.with_query({"test": "true", "foo": "bar"}) + async with session.request("GET", url.path_qs) as resp: + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_with_query_parameter_tuple_list(pact: Pact) -> None: + ( + pact + .upon_receiving("a basic request with a query parameter from a dict") + .with_request("GET", "/") + .with_query_parameters([("test", "true"), ("foo", "bar")]) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + url = srv.url.with_query({"test": "true", "foo": "bar"}) + async with session.request("GET", url.path_qs) as resp: + assert resp.status == 200 + + +@pytest.mark.parametrize( + "method", + ["GET", "POST", "PUT"], +) +@pytest.mark.asyncio +async def test_with_body_request(pact: Pact, method: str) -> None: + ( + pact + .upon_receiving(f"a basic {method} request with a body") + .with_request(method, "/") + .with_body(json.dumps({"test": True}), "application/json") + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + method, + "/", + json={"test": True}, + ) as resp: + assert resp.status == 200 + + +@pytest.mark.parametrize( + "method", + ["GET", "POST", "PUT"], +) +@pytest.mark.asyncio +async def test_with_body_response(pact: Pact, method: str) -> None: + ( + pact + .upon_receiving( + f"a basic {method} request expecting a response with a body", + ) + .with_request(method, "/") + .will_respond_with(200) + .with_body(json.dumps({"test": True}), "application/json") + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + method, + "/", + json={"test": True}, + ) as resp: + assert resp.status == 200 + assert json.loads(await resp.content.read()) == {"test": True} + + +@pytest.mark.asyncio +async def test_with_body_explicit(pact: Pact) -> None: + ( + pact + .upon_receiving("") + .with_request("GET", "/") + .will_respond_with(200) + .with_body(json.dumps({"request": True}), "application/json", "Request") + .with_body(json.dumps({"response": True}), "application/json", "Response") + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + json={"request": True}, + ) as resp: + assert resp.status == 200 + assert json.loads(await resp.content.read()) == {"response": True} + + +def test_with_body_invalid(pact: Pact) -> None: + with pytest.raises(ValueError, match="Invalid part: Invalid"): + ( + pact + .upon_receiving("") + .with_request("GET", "/") + .will_respond_with(200) + .with_body( + json.dumps({"request": True}), + "application/json", + "Invalid", # type: ignore[arg-type] + ) + ) + + +@pytest.mark.asyncio +async def test_given(pact: Pact) -> None: + ( + pact + .upon_receiving("a basic request given state 1") + .given("state 1") + .with_request("GET", "/state") + .will_respond_with(200) + ) + ( + pact + .upon_receiving("a basic request given a user exists (1)") + .given("a user exists", id=123) + .given("a user exists", name="John") + .with_request("GET", "/user1") + .will_respond_with(201) + ) + ( + pact + .upon_receiving("a basic request given a user exists (2)") + .given("a user exists", {"id": "123", "name": "John"}) + .with_request("GET", "/user2") + .will_respond_with(202) + ) + + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request("GET", "/state") as resp: + assert resp.status == 200 + async with session.request("GET", "/user1") as resp: + assert resp.status == 201 + async with session.request("GET", "/user2") as resp: + assert resp.status == 202 + + +@pytest.mark.asyncio +async def test_binary_file_request(pact: Pact) -> None: + payload = bytes(range(8)) + ( + pact + .upon_receiving("a basic request with a binary file") + .with_request("POST", "/") + .with_binary_body(payload, "application/octet-stream") + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.post("/", data=payload) as resp: + assert resp.status == 200 + + with pytest.raises(MismatchesError), pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.post("/", data=payload[:2]) as resp: + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_binary_file_response(pact: Pact) -> None: + payload = bytes(range(5)) + ( + pact + .upon_receiving("a basic request with a binary file response") + .with_request("GET", "/") + .will_respond_with(200) + .with_binary_body(payload, "application/bytes") + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.get("/") as resp: + assert resp.status == 200 + assert await resp.read() == payload + assert payload == bytes(range(5)) # to make sure it's not mutated + assert resp.headers["Content-Type"] == "application/bytes" + + +@pytest.mark.skip(reason="Not working yet") +@pytest.mark.asyncio +async def test_multipart_file_request(pact: Pact, temp_assets: Path) -> None: + fpy = temp_assets / "test.py" + fpng = temp_assets / "test.png" + ( + pact + .upon_receiving("a basic request with a multipart file") + .with_request("POST", "/") + .with_multipart_file( + fpy.name, + fpy, + "text/x-python", + ) + .with_multipart_file( + fpng.name, + fpng, + "image/png", + ) + .will_respond_with(200) + ) + with pact.serve() as srv, aiohttp.MultipartWriter() as mpwriter: + mpwriter.append( + fpy.open("rb"), + # TODO: Remove type ignore once aio-libs/aiohttp#7741 is resolved + # https://github.com/pact-foundation/pact-python/issues/450 + {"Content-Type": "text/x-python"}, # type: ignore[arg-type] + ) + mpwriter.append( + fpng.open("rb"), + # TODO: Remove type ignore once aio-libs/aiohttp#7741 is resolved + # https://github.com/pact-foundation/pact-python/issues/450 + {"Content-Type": "image/png"}, # type: ignore[arg-type] + ) + + async with ( + aiohttp.ClientSession(srv.url) as session, + session.post( + "/", + data=mpwriter, + ) as resp, + ): + assert resp.status == 200 + assert await resp.read() == b"" + + +@pytest.mark.asyncio +async def test_name(pact: Pact) -> None: + ( + pact + .upon_receiving("a basic request with a test name") + .test_name("a test name") + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.get("/") as resp: + assert resp.status == 200 + assert await resp.read() == b"" + + +def test_add_external_reference(pact: Pact, tmp_path: Path) -> None: + ( + pact + .upon_receiving("a request with an external reference") + .add_external_reference( + "Jira", + "TICKET-123", + "https://jira.example.com/browse/TICKET-123", + ) + .with_request("GET", "/") + .will_respond_with(200) + ) + pact.write_file(tmp_path) + data = json.load((tmp_path / "consumer-provider.json").open()) + references = data["interactions"][0]["comments"]["references"] + assert ( + references["Jira"]["TICKET-123"] == "https://jira.example.com/browse/TICKET-123" + ) + + +def test_add_external_reference_multiple(pact: Pact, tmp_path: Path) -> None: + ( + pact + .upon_receiving("a request with multiple external references") + .add_external_reference( + "Jira", "TICKET-123", "https://jira.example.com/TICKET-123" + ) + .add_external_reference( + "GitHub", "PR-456", "https://github.com/org/repo/pull/456" + ) + .with_request("GET", "/") + .will_respond_with(200) + ) + pact.write_file(tmp_path) + data = json.load((tmp_path / "consumer-provider.json").open()) + references = data["interactions"][0]["comments"]["references"] + assert references["Jira"]["TICKET-123"] == "https://jira.example.com/TICKET-123" + assert references["GitHub"]["PR-456"] == "https://github.com/org/repo/pull/456" + + +@pytest.mark.asyncio +async def test_with_plugin(pact: Pact) -> None: + ( + pact + .upon_receiving("a basic request with a plugin") + .with_plugin_contents("{}", "application/json") + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.get("/") as resp: + assert resp.status == 200 + assert await resp.read() == b"" + + +@pytest.mark.asyncio +async def test_pact_server_verbose( + pact: Pact, + caplog: pytest.LogCaptureFixture, +) -> None: + ( + pact + .upon_receiving("a basic request with a plugin") + .with_request("GET", "/foo") + .will_respond_with(200) + ) + with ( + caplog.at_level(logging.WARNING, logger="pact.pact"), + pact.serve(raises=False, verbose=True) as srv, + ): + async with aiohttp.ClientSession(srv.url) as session: + async with session.get("/bar") as resp: + assert resp.status == 500 + + assert len(caplog.records) == 1 + for record in caplog.records: + assert record.levelname == "ERROR" + assert record.message.startswith("Mismatches:\n") diff --git a/tests/interaction/test_sync_message_interaction.py b/tests/interaction/test_sync_message_interaction.py new file mode 100644 index 000000000..ef3603da6 --- /dev/null +++ b/tests/interaction/test_sync_message_interaction.py @@ -0,0 +1,134 @@ +""" +Pact Sync Message Interaction unit tests. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import pytest + +from pact import Pact + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def pact() -> Pact: + """ + Fixture for a Pact instance. + """ + return Pact("consumer", "provider").with_specification("V4") + + +def test_str(pact: Pact) -> None: + interaction = pact.upon_receiving("a basic request", "Sync") + assert str(interaction) == "SyncMessageInteraction(a basic request)" + + +def test_repr(pact: Pact) -> None: + interaction = pact.upon_receiving("a basic request", "Sync") + assert ( + re.match( + r"^SyncMessageInteraction\(InteractionHandle\(\d+\)\)$", + repr(interaction), + ) + is not None + ) + + +def test_with_metadata_with_positional_dict(pact: Pact) -> None: + ( + pact + .upon_receiving("with_metadatadict", "Sync") + .with_body("request", content_type="text/plain") + .with_metadata({"foo": "bar"}) + .will_respond_with() + .with_body("response", content_type="text/plain") + ) + handler = MagicMock() + handler.return_value = "response" + pact.verify(handler, "Sync") + handler.assert_called_once() + assert "foo" in handler.call_args[0][1] + assert handler.call_args[0][1]["foo"] == "bar" + + +def test_with_metadata_with_keyword_args(pact: Pact) -> None: + ( + pact + .upon_receiving("with_metadata_kwargs", "Sync") + .with_body("request", content_type="text/plain") + .with_metadata(foo="bar") + .will_respond_with() + .with_body("response", content_type="text/plain") + ) + handler = MagicMock() + handler.return_value = "response" + pact.verify(handler, "Sync") + handler.assert_called_once() + assert "foo" in handler.call_args[0][1] + assert handler.call_args[0][1]["foo"] == "bar" + + +def test_with_metadata_with_mixed_args(pact: Pact) -> None: + ( + pact + .upon_receiving("with_metadata_mixed", "Sync") + .with_body("request", content_type="text/plain") + .with_metadata({"foo": {"bar": 1.23}}, metadata=123) + .will_respond_with() + .with_body("response", content_type="text/plain") + ) + handler = MagicMock() + handler.return_value = "response" + pact.verify(handler, "Sync") + handler.assert_called_once() + assert "foo" in handler.call_args[0][1] + assert handler.call_args[0][1]["foo"] == {"bar": 1.23} + assert "metadata" in handler.call_args[0][1] + assert handler.call_args[0][1]["metadata"] == 123 + + +def test_with_metadata_with_part(pact: Pact) -> None: + ( + pact + .upon_receiving("with_metadata_part", "Sync") + .with_body("request", content_type="text/plain") + .will_respond_with() + .with_body("response", content_type="text/plain") + .with_metadata({"foo": {"bar": 1.23}}, "Request", metadata=123) + ) + handler = MagicMock() + handler.return_value = "response" + pact.verify(handler, "Sync") + handler.assert_called_once() + assert "foo" in handler.call_args[0][1] + assert handler.call_args[0][1]["foo"] == {"bar": 1.23} + assert "metadata" in handler.call_args[0][1] + assert handler.call_args[0][1]["metadata"] == 123 + + +def test_add_external_reference(pact: Pact, tmp_path: Path) -> None: + ( + pact + .upon_receiving("a sync message with an external reference", "Sync") + .add_external_reference( + "Jira", + "TICKET-789", + "https://jira.example.com/browse/TICKET-789", + ) + .with_body("request", content_type="text/plain") + .will_respond_with() + .with_body("response", content_type="text/plain") + ) + pact.write_file(tmp_path) + data = json.load((tmp_path / "consumer-provider.json").open()) + references = data["interactions"][0]["comments"]["references"] + assert ( + references["Jira"]["TICKET-789"] == "https://jira.example.com/browse/TICKET-789" + ) diff --git a/tests/pacts/.gitignore b/tests/pacts/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/tests/pacts/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/test_constants.py b/tests/test_constants.py deleted file mode 100644 index 38bc48b90..000000000 --- a/tests/test_constants.py +++ /dev/null @@ -1,67 +0,0 @@ -from unittest import TestCase - -from mock import patch - -from pact import constants as constants - - -class BrokerClientExeTestCase(TestCase): - def setUp(self): - super(BrokerClientExeTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_os = patch.object(constants, 'os', autospec=True).start() - - def test_other(self): - self.mock_os.name = 'posix' - self.assertEqual(constants.broker_client_exe(), 'pact-broker') - - def test_windows(self): - self.mock_os.name = 'nt' - self.assertEqual(constants.broker_client_exe(), 'pact-broker.bat') - - -class MockServiceExeTestCase(TestCase): - def setUp(self): - super(MockServiceExeTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_os = patch.object(constants, 'os', autospec=True).start() - - def test_other(self): - self.mock_os.name = 'posix' - self.assertEqual(constants.mock_service_exe(), 'pact-mock-service') - - def test_windows(self): - self.mock_os.name = 'nt' - self.assertEqual(constants.mock_service_exe(), 'pact-mock-service.bat') - - -class MessageExeTestCase(TestCase): - def setUp(self): - super(MessageExeTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_os = patch.object(constants, 'os', autospec=True).start() - - def test_other(self): - self.mock_os.name = 'posix' - self.assertEqual(constants.message_exe(), 'pact-message') - - def test_windows(self): - self.mock_os.name = 'nt' - self.assertEqual(constants.message_exe(), 'pact-message.bat') - - -class ProviderVerifierExeTestCase(TestCase): - def setUp(self): - super(ProviderVerifierExeTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_os = patch.object(constants, 'os', autospec=True).start() - - def test_other(self): - self.mock_os.name = 'posix' - self.assertEqual( - constants.provider_verifier_exe(), 'pact-provider-verifier') - - def test_windows(self): - self.mock_os.name = 'nt' - self.assertEqual( - constants.provider_verifier_exe(), 'pact-provider-verifier.bat') diff --git a/tests/test_error.py b/tests/test_error.py new file mode 100644 index 000000000..7b57f3b7d --- /dev/null +++ b/tests/test_error.py @@ -0,0 +1,266 @@ +""" +Error handling and mismatch tests. +""" + +from __future__ import annotations + +import re + +import aiohttp +import pytest + +from pact import Pact +from pact.error import ( + BodyMismatch, + BodyTypeMismatch, + HeaderMismatch, + MismatchesError, + MissingRequest, + QueryMismatch, + RequestMismatch, + RequestNotFound, +) + + +@pytest.fixture +def pact() -> Pact: + """ + Fixture for a Pact instance. + """ + return Pact("consumer", "provider") + + +@pytest.mark.asyncio +async def test_missing_request(pact: Pact) -> None: + ( + pact + .upon_receiving("a missing request") + .with_request("GET", "/") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/nonexistent", + ): + pass + + assert len(exc.value.mismatches) == 2 + missing_request, request_not_found = sorted( + exc.value.mismatches, + key=lambda m: m.__class__.__name__, + ) + + assert isinstance(missing_request, MissingRequest) + assert missing_request.path == "/" + assert missing_request.method == "GET" + assert re.match(r"Missing request: GET /: \{.*\}", str(missing_request)) + + assert isinstance(request_not_found, RequestNotFound) + assert request_not_found.path == "/nonexistent" + assert request_not_found.method == "GET" + assert re.match( + r"Request not found: GET /nonexistent: \{.*\}", str(request_not_found) + ) + + +@pytest.mark.asyncio +async def test_query_mismatch_value(pact: Pact) -> None: + ( + pact + .upon_receiving("a query mismatch") + .with_request("GET", "/resource") + .with_query_parameter("param", "expected") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/resource?param=actual", + ): + pass + + assert len(exc.value.mismatches) == 1 + request_mismatch = exc.value.mismatches[0] + + assert isinstance(request_mismatch, RequestMismatch) + assert request_mismatch.path == "/resource" + assert request_mismatch.method == "GET" + assert ( + str(request_mismatch) + == """Request mismatch: GET /resource + (1) Query mismatch: Expected query parameter 'param' \ +with value 'expected' but was 'actual'""" + ) + + query_mismatch = request_mismatch.mismatches[0] + assert isinstance(query_mismatch, QueryMismatch) + assert query_mismatch.parameter == "param" + assert query_mismatch.expected == "expected" + assert query_mismatch.actual == "actual" + assert str(query_mismatch) == ( + "Query mismatch: " + "Expected query parameter 'param' with value 'expected' but was 'actual'" + ) + + +@pytest.mark.asyncio +async def test_query_mismatch_different_keys(pact: Pact) -> None: + ( + pact + .upon_receiving("a query mismatch with different keys") + .with_request("GET", "/resource") + .with_query_parameter("key", "value") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/resource?foo=bar", + ): + pass + + assert len(exc.value.mismatches) == 1 + request_mismatch = exc.value.mismatches[0] + + assert isinstance(request_mismatch, RequestMismatch) + assert request_mismatch.path == "/resource" + assert request_mismatch.method == "GET" + + mismatches = sorted( + request_mismatch.mismatches, + key=lambda m: getattr(m, "parameter", ""), + ) + + mismatch = mismatches[0] + assert isinstance(mismatch, QueryMismatch) + assert mismatch.parameter == "foo" + assert mismatch.expected == "" + assert mismatch.actual == '["bar"]' + + mismatch = mismatches[1] + assert isinstance(mismatch, QueryMismatch) + assert mismatch.parameter == "key" + assert mismatch.expected == '["value"]' + assert mismatch.actual == "" + + +@pytest.mark.asyncio +async def test_header_mismatch(pact: Pact) -> None: + ( + pact + .upon_receiving("a header mismatch") + .with_request("GET", "/") + .with_header("X-Foo", "expected") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + headers={"X-Foo": "unexpected"}, + ): + pass + + assert len(exc.value.mismatches) == 1 + request_mismatch = exc.value.mismatches[0] + + assert isinstance(request_mismatch, RequestMismatch) + assert request_mismatch.path == "/" + assert request_mismatch.method == "GET" + + header_mismatch = request_mismatch.mismatches[0] + assert isinstance(header_mismatch, HeaderMismatch) + assert header_mismatch.key == "X-Foo" + assert header_mismatch.expected == "expected" + assert header_mismatch.actual == "unexpected" + assert str(header_mismatch) == ( + "Header mismatch: Mismatch with header 'X-Foo': " + "Expected 'unexpected' to be equal to 'expected'" + ) + + +@pytest.mark.asyncio +async def test_body_type_mismatch(pact: Pact) -> None: + ( + pact + .upon_receiving("a body type mismatch") + .with_request("POST", "/") + .with_body("{}", "application/json") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "POST", + "/", + headers={"Content-Type": "text/plain"}, + data="plain text", + ): + pass + + assert len(exc.value.mismatches) == 1 + request_mismatch = exc.value.mismatches[0] + assert isinstance(request_mismatch, RequestMismatch) + assert request_mismatch.path == "/" + assert request_mismatch.method == "POST" + + header_mismatch = request_mismatch.mismatches[0] + assert isinstance(header_mismatch, HeaderMismatch) + assert header_mismatch.key == "Content-Type" + assert header_mismatch.expected == "application/json" + assert header_mismatch.actual == "text/plain" + assert str(header_mismatch) == ( + "Header mismatch: Mismatch with header 'Content-Type': " + "Expected header 'Content-Type' to have value 'application/json' " + "but was 'text/plain'" + ) + + body_type_mismatch = request_mismatch.mismatches[1] + assert isinstance(body_type_mismatch, BodyTypeMismatch) + assert body_type_mismatch.expected == "application/json" + assert body_type_mismatch.actual == "text/plain" + assert str(body_type_mismatch) == ( + "Body type mismatch: Expected a body of 'application/json' " + "but the actual content type was 'text/plain'" + ) + + +@pytest.mark.asyncio +async def test_body_mismatch(pact: Pact) -> None: + ( + pact + .upon_receiving("a body mismatch") + .with_request("POST", "/") + .with_body("expected") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "POST", + "/", + data="unexpected", + ): + pass + + assert len(exc.value.mismatches) == 1 + request_mismatch = exc.value.mismatches[0] + + assert isinstance(request_mismatch, RequestMismatch) + assert request_mismatch.path == "/" + assert request_mismatch.method == "POST" + + body_mismatch = request_mismatch.mismatches[0] + assert isinstance(body_mismatch, BodyMismatch) + assert body_mismatch.path == "$" + assert body_mismatch.expected == "expected" + assert body_mismatch.actual == "unexpected" + assert str(body_mismatch) == ( + "Body mismatch: Expected body 'expected' to match 'unexpected' " + "using equality but did not match" + ) diff --git a/tests/test_match.py b/tests/test_match.py new file mode 100644 index 000000000..0ed9de7a9 --- /dev/null +++ b/tests/test_match.py @@ -0,0 +1,243 @@ +""" +Example test to show usage of matchers (and generators by extension). +""" + +from __future__ import annotations + +import logging +import re +import time +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path +from random import randint, uniform +from threading import Thread +from typing import TYPE_CHECKING + +import requests +from flask import Flask, Response, make_response +from yarl import URL + +import pact._util +from pact import Pact, Verifier, generate, match + +if TYPE_CHECKING: + from collections.abc import Generator + +logger = logging.getLogger(__name__) + + +@contextmanager +def start_provider() -> Generator[URL, None, None]: + """ + Start the provider app using a daemon thread. + + Yields: + The base URL of the running Flask server. + """ + hostname = "127.0.0.1" + port = pact._util.find_free_port() # noqa: SLF001 + + Thread( + target=app.run, + kwargs={"host": hostname, "port": port, "use_reloader": False}, + daemon=True, + ).start() + + url = URL(f"http://{hostname}:{port}") + + for _ in range(50): + try: + response = requests.get(str(url / "_test" / "ping"), timeout=1) + assert response.text == "pong" + break + except (requests.RequestException, AssertionError): + time.sleep(0.1) + else: + msg = "Failed to ping provider" + raise RuntimeError(msg) + + yield url + + +app = Flask(__name__) + + +@app.route("/path/to/") +def hello_world(test_id: int) -> Response: + random_regex_matches = "1-8 digits: 12345678, 1-8 random letters abcdefgh" + response = make_response({ + "response": { + "id": test_id, + "regexMatches": "must end with 'hello world'", + "randomRegexMatches": random_regex_matches, + "integerMatches": test_id, + "decimalMatches": round(uniform(0, 9), 3), # noqa: S311 + "booleanMatches": True, + "randomIntegerMatches": randint(1, 100), # noqa: S311 + "randomDecimalMatches": round(uniform(0, 9), 1), # noqa: S311 + "randomStringMatches": "hi there", + "includeMatches": "hello world", + "includeWithGeneratorMatches": "say 'hello world' for me", + "minMaxArrayMatches": [ + round(uniform(0, 9), 1) # noqa: S311 + for _ in range(randint(3, 5)) # noqa: S311 + ], + "arrayContainingMatches": [randint(1, 100), randint(1, 100)], # noqa: S311 + "numbers": { + "intMatches": 42, + "floatMatches": 3.1415, + "intGeneratorMatches": randint(1, 100), # noqa: S311, + "decimalGeneratorMatches": round(uniform(10, 99), 2), # noqa: S311 + }, + "dateMatches": "1999-12-31", + "randomDateMatches": "1999-12-31", + "timeMatches": "12:34:56", + "timestampMatches": datetime.now().isoformat(), # noqa: DTZ005 + "nullMatches": None, + "eachKeyMatches": { + "id_1": { + "name": "John Doe", + }, + "id_2": { + "name": "Jane Doe", + }, + }, + } + }) + response.headers["SpecialHeader"] = "Special: Hi" + return response + + +@app.get("/_test/ping") +def ping() -> str: + """Simple ping endpoint for testing.""" + return "pong" + + +if __name__ == "__main__": + app.run() + + +def test_matchers() -> None: + pact_dir = Path(Path(__file__).parent.parent.parent / "pacts") + pact = Pact("consumer", "provider").with_specification("V4") + ( + pact + .upon_receiving("a request") + .given("a state", {"providerStateArgument": "providerStateValue"}) + .with_request("GET", match.regex("/path/to/100", regex=r"/path/to/\d{1,4}")) + .with_query_parameter( + "asOf", + match.like( + [match.date("2024-01-01", format="%Y-%m-%d")], + min=1, + max=1, + ), + ) + .will_respond_with(200) + .with_body({ + "response": match.like( + { + "regexMatches": match.regex( + "must end with 'hello world'", + regex=r"^.*hello world'$", + ), + "randomRegexMatches": match.regex( + regex=r"1-8 digits: \d{1,8}, 1-8 random letters \w{1,8}" + ), + "integerMatches": match.int(42), + "decimalMatches": match.decimal(3.1415), + "randomIntegerMatches": match.int(min=1, max=100), + "randomDecimalMatches": match.decimal(precision=4), + "booleanMatches": match.bool(False), + "randomStringMatches": match.string(size=10), + "includeMatches": match.includes("world"), + "includeWithGeneratorMatches": match.includes( + "world", + generator=generate.regex(r"\d{1,8} (hello )?world \d+"), + ), + "minMaxArrayMatches": match.each_like( + match.number(1.23, precision=2), + min=3, + max=5, + ), + "arrayContainingMatches": match.array_containing([ + match.int(1), + match.int(2), + ]), + "numbers": { + "intMatches": match.number(42), + "floatMatches": match.number(3.1415), + "intGeneratorMatches": match.number(2, max=10), + "decimalGeneratorMatches": match.number(3.1415, precision=4), + }, + "dateMatches": match.date("2024-01-01", format="%Y-%m-%d"), + "randomDateMatches": match.date(format="%Y-%m-%d"), + "timeMatches": match.time("12:34:56", format="%H:%M:%S"), + "timestampMatches": match.timestamp( + "2024-01-01T12:34:56.000000", + format="%Y-%m-%dT%H:%M:%S.%f", + ), + "nullMatches": match.null(), + "eachKeyMatches": match.each_key_matches( + { + "id_1": match.each_value_matches( + {"name": match.string(size=30)}, + rules=match.string("John Doe"), + ) + }, + rules=match.regex("id_1", regex=r"^id_\d+$"), + ), + }, + min=1, + ) + }) + .with_header( + "SpecialHeader", match.regex("Special: Foo", regex=r"^Special: \w+$") + ) + ) + with pact.serve() as mockserver: + response = requests.get( + f"{mockserver.url}/path/to/35?asOf=2020-05-13", timeout=5 + ) + response_data = response.json() + # when a value is passed to a matcher, that value should be returned + assert ( + response_data["response"]["regexMatches"] == "must end with 'hello world'" + ) + assert response_data["response"]["integerMatches"] == 42 + assert response_data["response"]["booleanMatches"] is False + assert response_data["response"]["includeMatches"] == "world" + assert response_data["response"]["dateMatches"] == "2024-01-01" + assert response_data["response"]["timeMatches"] == "12:34:56" + assert ( + response_data["response"]["timestampMatches"] + == "2024-01-01T12:34:56.000000" + ) + assert response_data["response"]["arrayContainingMatches"] == [1, 2] + assert response_data["response"]["nullMatches"] is None + # when a value is not passed to a matcher, a value should be generated + random_regex_matcher = re.compile( + r"1-8 digits: \d{1,8}, 1-8 random letters \w{1,8}" + ) + assert random_regex_matcher.match( + response_data["response"]["randomRegexMatches"] + ) + random_integer = int(response_data["response"]["randomIntegerMatches"]) + assert random_integer >= 1 + assert random_integer <= 100 + float(response_data["response"]["randomDecimalMatches"]) + assert ( + len(response_data["response"]["randomDecimalMatches"].replace(".", "")) == 4 + ) + assert len(response_data["response"]["randomStringMatches"]) == 10 + + pact.write_file(pact_dir, overwrite=True) + with start_provider() as url: + verifier = ( + Verifier("My Provider", host="127.0.0.1") + .add_transport(url=url) + .add_source(pact_dir / "consumer-provider.json") + ) + verifier.verify() diff --git a/tests/test_pact.py b/tests/test_pact.py index 76e1e7059..ab8c2ec13 100644 --- a/tests/test_pact.py +++ b/tests/test_pact.py @@ -1,647 +1,237 @@ -import os -from subprocess import Popen -from unittest import TestCase - -from mock import patch, call, Mock -from psutil import Process - -from pact.broker import Broker -from pact.consumer import Consumer, Provider -from pact.matchers import Term -from pact.constants import MOCK_SERVICE_PATH -from pact.pact import Pact, FromTerms, Request, Response -from pact import pact as pact -from pact.verify_wrapper import PactException - - -class PactTestCase(TestCase): - def setUp(self): - self.consumer = Consumer('TestConsumer') - self.provider = Provider('TestProvider') - - def test_init_defaults(self): - target = Pact(self.consumer, self.provider) - self.assertIs(target.broker_base_url, None) - self.assertIs(target.broker_username, None) - self.assertIs(target.broker_password, None) - self.assertIs(target.consumer, self.consumer) - self.assertIs(target.cors, False) - self.assertEqual(target.host_name, 'localhost') - self.assertEqual(target.log_dir, os.getcwd()) - self.assertEqual(target.pact_dir, os.getcwd()) - self.assertEqual(target.port, 1234) - self.assertIs(target.provider, self.provider) - self.assertIs(target.publish_to_broker, False) - self.assertIs(target.ssl, False) - self.assertIsNone(target.sslcert) - self.assertIsNone(target.sslkey) - self.assertEqual(target.uri, 'http://localhost:1234') - self.assertEqual(target.specification_version, '2.0.0') - self.assertEqual(len(target._interactions), 0) - - def test_init_custom_mock_service(self): - target = Pact( - self.consumer, self.provider, host_name='192.168.1.1', port=8000, - log_dir='/logs', ssl=True, sslcert='/ssl.cert', sslkey='/ssl.pem', - cors=True, pact_dir='/pacts', specification_version='3.0.0', - file_write_mode='merge') - - self.assertIs(target.consumer, self.consumer) - self.assertIs(target.cors, True) - self.assertEqual(target.host_name, '192.168.1.1') - self.assertEqual(target.log_dir, '/logs') - self.assertEqual(target.pact_dir, '/pacts') - self.assertEqual(target.port, 8000) - self.assertIs(target.provider, self.provider) - self.assertIs(target.ssl, True) - self.assertEqual(target.sslcert, '/ssl.cert') - self.assertEqual(target.sslkey, '/ssl.pem') - self.assertEqual(target.uri, 'https://192.168.1.1:8000') - self.assertEqual(target.specification_version, '3.0.0') - self.assertEqual(target.file_write_mode, 'merge') - self.assertEqual(len(target._interactions), 0) - - def test_init_publish_to_broker(self): - target = Pact( - self.consumer, self.provider, publish_to_broker=True, - broker_base_url='http://localhost', broker_username='username', - broker_password='password', broker_token='token') - - self.assertEqual(target.broker_base_url, 'http://localhost') - self.assertEqual(target.broker_username, 'username') - self.assertEqual(target.broker_password, 'password') - self.assertEqual(target.broker_token, 'token') - self.assertIs(target.publish_to_broker, True) - - def test_definition_sparse(self): - target = Pact(self.consumer, self.provider) - (target - .given('I am creating a new pact using the Pact class') - .upon_receiving('a specific request to the server') - .with_request('GET', '/path') - .will_respond_with(200, body='success')) - - self.assertEqual(len(target._interactions), 1) - - self.assertEqual( - target._interactions[0]['provider_state'], - 'I am creating a new pact using the Pact class') - - self.assertEqual( - target._interactions[0]['description'], - 'a specific request to the server') - - self.assertEqual(target._interactions[0]['request'], - {'path': '/path', 'method': 'GET'}) - self.assertEqual(target._interactions[0]['response'], - {'status': 200, 'body': 'success'}) - - def test_definition_without_given(self): - target = Pact(self.consumer, self.provider) - (target - .upon_receiving('a specific request to the server') - .with_request('GET', '/path') - .will_respond_with(200, body='success')) - - self.assertEqual(len(target._interactions), 1) - - self.assertIsNone( - target._interactions[0].get('provider_state')) - - self.assertEqual( - target._interactions[0]['description'], - 'a specific request to the server') - - self.assertEqual(target._interactions[0]['request'], - {'path': '/path', 'method': 'GET'}) - self.assertEqual(target._interactions[0]['response'], - {'status': 200, 'body': 'success'}) - - def test_definition_all_options(self): - target = Pact(self.consumer, self.provider) - (target - .given('I am creating a new pact using the Pact class') - .upon_receiving('a specific request to the server') - .with_request('GET', '/path', - body={'key': 'value'}, - headers={'Accept': 'application/json'}, - query={'search': 'test'}) - .will_respond_with( - 200, - body='success', headers={'Content-Type': 'application/json'})) - - self.assertEqual( - target._interactions[0]['provider_state'], - 'I am creating a new pact using the Pact class') - - self.assertEqual( - target._interactions[0]['description'], - 'a specific request to the server') - - self.assertEqual(target._interactions[0]['request'], { - 'path': '/path', - 'method': 'GET', - 'body': {'key': 'value'}, - 'headers': {'Accept': 'application/json'}, - 'query': {'search': 'test'}}) - self.assertEqual(target._interactions[0]['response'], { - 'status': 200, - 'body': 'success', - 'headers': {'Content-Type': 'application/json'}}) - - def test_definition_multiple_interactions(self): - target = Pact(self.consumer, self.provider) - (target - .given('I am creating a new pact using the Pact class') - .upon_receiving('a specific request to the server') - .with_request('GET', '/foo') - .will_respond_with(200, body='success') - .given('I am creating another new pact using the Pact class') - .upon_receiving('a different request to the server') - .with_request('GET', '/bar') - .will_respond_with(200, body='success')) - - self.assertEqual(len(target._interactions), 2) - - self.assertEqual( - target._interactions[1]['provider_state'], - 'I am creating a new pact using the Pact class') - self.assertEqual( - target._interactions[0]['provider_state'], - 'I am creating another new pact using the Pact class') - - self.assertEqual( - target._interactions[1]['description'], - 'a specific request to the server') - self.assertEqual( - target._interactions[0]['description'], - 'a different request to the server') - - self.assertEqual(target._interactions[1]['request'], - {'path': '/foo', 'method': 'GET'}) - self.assertEqual(target._interactions[0]['request'], - {'path': '/bar', 'method': 'GET'}) - - self.assertEqual(target._interactions[1]['response'], - {'status': 200, 'body': 'success'}) - self.assertEqual(target._interactions[0]['response'], - {'status': 200, 'body': 'success'}) - - def test_definition_multiple_interactions_without_given(self): - target = Pact(self.consumer, self.provider) - (target - .upon_receiving('a specific request to the server') - .with_request('GET', '/foo') - .will_respond_with(200, body='success') - .upon_receiving('a different request to the server') - .with_request('GET', '/bar') - .will_respond_with(200, body='success')) - - self.assertEqual(len(target._interactions), 2) - - self.assertIsNone( - target._interactions[1].get('provider_state')) - self.assertIsNone( - target._interactions[0].get('provider_state')) - - self.assertEqual( - target._interactions[1]['description'], - 'a specific request to the server') - self.assertEqual( - target._interactions[0]['description'], - 'a different request to the server') - - self.assertEqual(target._interactions[1]['request'], - {'path': '/foo', 'method': 'GET'}) - self.assertEqual(target._interactions[0]['request'], - {'path': '/bar', 'method': 'GET'}) - - self.assertEqual(target._interactions[1]['response'], - {'status': 200, 'body': 'success'}) - self.assertEqual(target._interactions[0]['response'], - {'status': 200, 'body': 'success'}) - -class PactSetupTestCase(PactTestCase): - def setUp(self): - super(PactSetupTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_requests = patch('requests.api.request').start() - self.target = Pact(self.consumer, self.provider) - (self.target - .given('I am creating a new pact using the Pact class') - .upon_receiving('a specific request to the server') - .with_request('GET', '/path') - .will_respond_with(200, body='success')) - - self.delete_call = call('delete', 'http://localhost:1234/interactions', - headers={'X-Pact-Mock-Service': 'true'}, - verify=False) - - self.put_interactions_call = call( - 'put', 'http://localhost:1234/interactions', - data=None, - headers={'X-Pact-Mock-Service': 'true'}, - verify=False, - json={'interactions': [{ - 'response': {'status': 200, 'body': 'success'}, - 'request': {'path': '/path', 'method': 'GET'}, - 'description': 'a specific request to the server', - 'provider_state': 'I am creating a new pact using the ' - 'Pact class'}]}) - - def test_error_deleting_interactions(self): - self.mock_requests.side_effect = iter([ - Mock(status_code=500, text='deletion error')]) - - with self.assertRaises(AssertionError) as e: - self.target.setup() - - self.assertEqual(str(e.exception), 'deletion error') - self.assertEqual(self.mock_requests.call_count, 1) - self.mock_requests.assert_has_calls([self.delete_call]) - - def test_error_posting_interactions(self): - self.mock_requests.side_effect = iter([ - Mock(status_code=200), - Mock(status_code=500, text='post interactions error')]) - - with self.assertRaises(AssertionError) as e: - self.target.setup() - - self.assertEqual(str(e.exception), 'post interactions error') - self.assertEqual(self.mock_requests.call_count, 2) - self.mock_requests.assert_has_calls( - [self.delete_call, self.put_interactions_call]) - - def test_successful(self): - self.mock_requests.side_effect = iter([Mock(status_code=200)] * 4) - self.target.setup() - - self.assertEqual(self.mock_requests.call_count, 2) - self.mock_requests.assert_has_calls([ - self.delete_call, self.put_interactions_call]) - - -class PactStartShutdownServerTestCase(TestCase): - def setUp(self): - super(PactStartShutdownServerTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_Popen = patch.object(pact, 'Popen', autospec=True).start() - self.mock_Popen.return_value.returncode = 0 - self.mock_Process = patch.object( - pact.psutil, 'Process', autospec=True).start() - self.mock_platform = patch.object( - pact.platform, 'platform', autospec=True).start() - self.mock_wait_for_server_start = patch.object( - pact.Pact, '_wait_for_server_start', autospec=True).start() - self.mock_Pid_exists = patch.object( - pact.psutil, 'pid_exists', autospec=True).start() - self.mock_publish = patch.object( - Broker, 'publish', autospec=True).start() - - def test_start_fails(self): - self.mock_Popen.return_value.returncode = 1 - self.mock_wait_for_server_start.side_effect = RuntimeError - pact = Pact(Consumer('consumer'), Provider('provider'), - log_dir='/logs', pact_dir='/pacts') - - with self.assertRaises(RuntimeError): - pact.start_service() - - self.mock_Popen.assert_called_once_with([ - MOCK_SERVICE_PATH, 'service', - '--host=localhost', - '--port=1234', - '--log', '/logs/pact-mock-service.log', - '--pact-dir', '/pacts', - '--pact-file-write-mode', 'overwrite', - '--pact-specification-version=2.0.0', - '--consumer', 'consumer', - '--provider', 'provider']) - - def test_start_no_ssl(self): - pact = Pact(Consumer('consumer'), Provider('provider'), - log_dir='/logs', pact_dir='/pacts') - pact.start_service() - - self.mock_Popen.assert_called_once_with([ - MOCK_SERVICE_PATH, 'service', - '--host=localhost', - '--port=1234', - '--log', '/logs/pact-mock-service.log', - '--pact-dir', '/pacts', - '--pact-file-write-mode', 'overwrite', - '--pact-specification-version=2.0.0', - '--consumer', 'consumer', - '--provider', 'provider']) - - def test_start_with_ssl(self): - pact = Pact(Consumer('consumer'), Provider('provider'), - log_dir='/logs', pact_dir='/pacts', - ssl=True, sslcert='/ssl.cert', sslkey='/ssl.key') - pact.start_service() - - self.mock_Popen.assert_called_once_with([ - MOCK_SERVICE_PATH, 'service', - '--host=localhost', - '--port=1234', - '--log', '/logs/pact-mock-service.log', - '--pact-dir', '/pacts', - '--pact-file-write-mode', 'overwrite', - '--pact-specification-version=2.0.0', - '--consumer', 'consumer', - '--provider', 'provider', - '--ssl', - '--sslcert', '/ssl.cert', - '--sslkey', '/ssl.key']) - - def test_stop_posix(self): - self.mock_publish.return_value.returncode = 0 - self.mock_platform.return_value = 'Linux' - pact = Pact(Consumer('consumer'), Provider('provider')) - pact._process = Mock(spec=Popen, pid=999, returncode=0) - pact.stop_service() - - pact._process.terminate.assert_called_once_with() - pact._process.communicate.assert_called_once_with() - self.mock_publish.assert_not_called() - self.assertFalse(self.mock_Process.called) - - def test_stop_windows(self): - self.mock_platform.return_value = 'Windows' - ruby_exe = Mock(spec=Process) - self.mock_Process.return_value.children.return_value = [ruby_exe] - self.mock_Pid_exists.return_value = False - pact = Pact(Consumer('consumer', version='abc'), Provider('provider'), publish_to_broker=True, pact_dir='some_dir') - pact._process = Mock(spec=Popen, pid=999) - pact.stop_service() - - self.assertFalse(pact._process.terminate.called) - self.assertFalse(pact._process.communicate.called) - self.mock_Process.assert_called_once_with(999) - self.mock_Process.return_value.children.assert_called_once_with( - recursive=True) - ruby_exe.terminate.assert_called_once_with() - self.mock_Process.return_value.wait.assert_called_once_with() - self.mock_Pid_exists.assert_called_once_with(999) - self.mock_publish.assert_called_once_with( - pact, - 'consumer', - 'abc', - consumer_tags=None, - tag_with_git_branch=False, - pact_dir='some_dir', - branch=None, - build_url=None, - auto_detect_version_properties=False) - - def test_stop_fails_posix(self): - self.mock_platform.return_value = 'Linux' - self.mock_Popen.return_value.returncode = 1 - pact = Pact(Consumer('consumer'), Provider('provider')) - pact._process = Mock(spec=Popen, pid=999, returncode=1) - with self.assertRaises(RuntimeError): - pact.stop_service() - - pact._process.terminate.assert_called_once_with() - pact._process.communicate.assert_called_once_with() - self.mock_publish.assert_not_called() - - def test_stop_fails_windows(self): - self.mock_platform.return_value = 'Windows' - self.mock_Popen.return_value.returncode = 15 - self.mock_Pid_exists.return_value = True - - pact = Pact(Consumer('consumer'), Provider('provider')) - pact._process = Mock(spec=Popen, pid=999, returncode=15) - with self.assertRaises(RuntimeError): - pact.stop_service() - - self.assertFalse(pact._process.terminate.called) - self.assertFalse(pact._process.communicate.called) - self.mock_Process.assert_called_once_with(999) - self.mock_Process.return_value.children.assert_called_once_with( - recursive=True) - self.mock_Process.return_value.wait.assert_called_once_with() - self.mock_Pid_exists.assert_called_once_with(999) - self.mock_publish.assert_not_called() - - -class PactWaitForServerStartTestCase(TestCase): - def setUp(self): - super(PactWaitForServerStartTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_HTTPAdapter = patch.object( - pact, 'HTTPAdapter', autospec=True).start() - self.mock_Retry = patch.object(pact, 'Retry', autospec=True).start() - self.mock_Session = patch.object( - pact.requests, 'Session', autospec=True).start() - - def test_wait_for_server_start_success(self): - self.mock_Session.return_value.get.return_value.status_code = 200 - pact = Pact(Consumer('consumer'), Provider('provider')) - pact._process = Mock(spec=Popen) - pact._wait_for_server_start() - - session = self.mock_Session.return_value - session.mount.assert_called_once_with( - 'http://', self.mock_HTTPAdapter.return_value) - session.get.assert_called_once_with( - 'http://localhost:1234', - headers={'X-Pact-Mock-Service': 'true'}, - verify=False) - self.mock_HTTPAdapter.assert_called_once_with( - max_retries=self.mock_Retry.return_value) - self.mock_Retry.assert_called_once_with(total=9, backoff_factor=0.1) - self.assertFalse(pact._process.communicate.called) - self.assertFalse(pact._process.terminate.called) - - def test_wait_for_server_start_failure(self): - self.mock_Session.return_value.get.return_value.status_code = 500 - pact = Pact(Consumer('consumer'), Provider('provider')) - pact._process = Mock(spec=Popen) - with self.assertRaises(RuntimeError): - pact._wait_for_server_start() - - session = self.mock_Session.return_value - session.mount.assert_called_once_with( - 'http://', self.mock_HTTPAdapter.return_value) - session.get.assert_called_once_with( - 'http://localhost:1234', - headers={'X-Pact-Mock-Service': 'true'}, - verify=False) - self.mock_HTTPAdapter.assert_called_once_with( - max_retries=self.mock_Retry.return_value) - self.mock_Retry.assert_called_once_with(total=9, backoff_factor=0.1) - pact._process.communicate.assert_called_once_with() - pact._process.terminate.assert_called_once_with() - - -class PactVerifyTestCase(PactTestCase): - def setUp(self): - super(PactVerifyTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_requests = patch('requests.api.request').start() - self.target = Pact(self.consumer, self.provider) - (self.target - .given('I am creating a new pact using the Pact class') - .upon_receiving('a specific request to the server') - .with_request('GET', '/path') - .will_respond_with(200, body='success')) - self.get_verification_call = call( - 'get', 'http://localhost:1234/interactions/verification', - headers={'X-Pact-Mock-Service': 'true'}, - verify=False, - params=None) - - self.post_publish_pacts_call = call( - 'post', 'http://localhost:1234/pact', - data=None, - headers={'X-Pact-Mock-Service': 'true'}, - verify=False, - json=None) - - def test_success(self): - self.mock_requests.side_effect = iter([Mock(status_code=200)] * 2) - self.target.verify() - - self.assertEqual(self.mock_requests.call_count, 2) - self.mock_requests.assert_has_calls([ - self.get_verification_call, self.post_publish_pacts_call]) - - def test_error_verifying_interactions(self): - self.mock_requests.side_effect = iter([ - Mock(status_code=500, text='verification error')]) - - with self.assertRaises(AssertionError) as e: - self.target.verify() - - self.assertEqual(str(e.exception), 'verification error') - self.assertEqual(self.mock_requests.call_count, 1) - self.mock_requests.assert_has_calls([ - self.get_verification_call]) - - def test_error_writing_pacts_to_file(self): - self.mock_requests.side_effect = iter([ - Mock(status_code=200), - Mock(status_code=500, text='error writing pact to file')]) - - with self.assertRaises(AssertionError) as e: - self.target.verify() - - self.assertEqual(str(e.exception), 'error writing pact to file') - self.assertEqual(self.mock_requests.call_count, 2) - self.mock_requests.assert_has_calls([ - self.get_verification_call, self.post_publish_pacts_call]) - - -class PactContextManagerTestCase(PactTestCase): - def setUp(self): - super(PactContextManagerTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.mock_setup = patch.object( - pact.Pact, 'setup', autospec=True).start() - - self.mock_verify = patch.object( - pact.Pact, 'verify', autospec=True).start() - - def test_successful(self): - pact = Pact(self.consumer, self.provider) - with pact: - pass - - self.mock_setup.assert_called_once_with(pact) - self.mock_verify.assert_called_once_with(pact) - - def test_context_raises_error(self): - pact = Pact(self.consumer, self.provider) - with self.assertRaises(RuntimeError): - with pact: - raise RuntimeError - - self.mock_setup.assert_called_once_with(pact) - self.assertFalse(self.mock_verify.called) - - -class PactContextManagerSetupTestCase(PactTestCase): - def test_definition_without_description(self): - # Description (populated from "given") is listed in the MANDATORY_FIELDS. - # Make sure if it isn't there that an exception is raised - pact = Pact(self.consumer, self.provider) - (pact.given("A request without a description") - .with_request('GET', '/path') - .will_respond_with(200, body='success')) - - self.assertEqual(len(pact._interactions), 1) - - self.assertTrue('description' not in pact._interactions[0]) - - # By using "with", __enter__ will call the setup method that will verify if this is present - with self.assertRaises(PactException): - with pact: - pact.verify() - - -class FromTermsTestCase(TestCase): - def test_json(self): - with self.assertRaises(NotImplementedError): - FromTerms().json() - - -class RequestTestCase(TestCase): - def test_sparse(self): - target = Request('GET', '/path') - result = target.json() - self.assertEqual(result, { - 'method': 'GET', - 'path': '/path'}) - - def test_all_options(self): - target = Request( - 'POST', '/path', - body='the content', - headers={'Accept': 'application/json'}, - query='term=test') - - result = target.json() - self.assertEqual(result, { - 'method': 'POST', - 'path': '/path', - 'body': 'the content', - 'headers': {'Accept': 'application/json'}, - 'query': 'term=test'}) - - def test_falsey_body(self): - target = Request('GET', '/path', body=[]) - result = target.json() - self.assertEqual(result, { - 'method': 'GET', - 'path': '/path', - 'body': []}) - - def test_matcher_in_path_gets_converted(self): - target = Request('GET', Term('\/.+', '/test-path')) # noqa: W605 - result = target.json() - self.assertTrue(isinstance(result['path'], dict)) - - -class ResponseTestCase(TestCase): - def test_sparse(self): - target = Response(200) - result = target.json() - self.assertEqual(result, {'status': 200}) - - def test_all_options(self): - target = Response( - 202, headers={'Content-Type': 'application/json'}, body='the body') - - result = target.json() - self.assertEqual(result, { - 'status': 202, - 'body': 'the body', - 'headers': {'Content-Type': 'application/json'}}) - - def test_falsey_body(self): - target = Response(200, body=[]) - result = target.json() - self.assertEqual(result, {'status': 200, 'body': []}) +""" +Pact unit tests. +""" + +from __future__ import annotations + +import itertools +import json +from typing import TYPE_CHECKING, Literal + +import pytest + +from pact import Pact +from pact_ffi import PactSpecification + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def pact() -> Pact: + """ + Fixture for a Pact instance. + """ + return Pact("consumer", "provider") + + +def test_init(pact: Pact) -> None: + assert pact.consumer == "consumer" + assert pact.provider == "provider" + assert str(pact) == "consumer -> provider" + assert repr(pact).startswith("") + + +def test_empty_consumer() -> None: + with pytest.raises(ValueError, match="Consumer name cannot be empty"): + Pact("", "provider") + + +def test_empty_provider() -> None: + with pytest.raises(ValueError, match="Provider name cannot be empty"): + Pact("consumer", "") + + +def test_serve(pact: Pact) -> None: + with pact.serve() as srv: + assert srv.port is not None + assert srv.port > 0 + assert srv.host == "localhost" + assert str(srv).startswith("http://localhost") + assert srv.url.scheme == "http" + assert srv.url.host == "localhost" + assert srv.url.path == "/" + assert srv / "foo" == srv.url / "foo" + assert str(srv / "foo") == f"http://localhost:{srv.port}/foo" + + +@pytest.mark.skip(reason="TODO: implement") +def test_using_plugin(pact: Pact) -> None: + pact.using_plugin("core/transport/http") + + +def test_metadata(pact: Pact) -> None: + pact.with_metadata("test", {"version": "1.2.3", "hash": "abcdef"}) + + +def test_invalid_interaction(pact: Pact) -> None: + with pytest.raises( + ValueError, + match=r"Invalid interaction type: .*", + ): + pact.upon_receiving("a basic request", "Invalid") # type: ignore[call-overload] + + +def test_write_file(pact: Pact, tmp_path: Path) -> None: + pact.write_file(tmp_path) + outfile = tmp_path / "consumer-provider.json" + assert outfile.exists() + assert outfile.is_file() + + data = json.load(outfile.open("r")) + assert data["consumer"]["name"] == "consumer" + assert data["provider"]["name"] == "provider" + assert len(data["interactions"]) == 0 + + +@pytest.mark.parametrize( + "version", + [ + "1", + "1.1", + "2", + "3", + "4", + "V1", + "V1.1", + "V2", + "V3", + "V4", + ], +) +def test_specification(pact: Pact, version: str) -> None: + pact.with_specification(version) + assert pact.specification == PactSpecification.from_str(version) + + +def test_server_log(pact: Pact) -> None: + with pact.serve() as srv: + assert srv.logs is not None + + +class TestInteractionsIter: + """ + Collection of for `pact.interactions` iterator tests. + """ + + @staticmethod + def _interaction_count( + pact: Pact, + interaction_type: Literal["HTTP", "Sync", "Async", "All"], + ) -> int: + """ + Count the number of interactions for the requested type. + + Args: + pact: + Pact instance under test. + + interaction_type: + Interaction type to count (HTTP, Async, Sync, All). + + Returns: + Number of interactions that match the provided type. + """ + return sum(1 for _ in pact.interactions(interaction_type)) + + @pytest.mark.parametrize( + "interaction_type", + [ + "HTTP", + "Sync", + "Async", + "All", + ], + ) + def test_empty( + self, + pact: Pact, + interaction_type: Literal[ + "HTTP", + "Sync", + "Async", + "All", + ], + ) -> None: + interactions = pact.interactions(interaction_type) + assert interactions is not None + for _interaction in interactions: + # This should be an empty list and therefore the error should never be + # raised. + msg = "Should not be reached" + raise RuntimeError(msg) + + @classmethod + def _add_http_interaction(cls, pact: Pact, id_: int) -> None: + ( + pact + .upon_receiving(f"HTTP request {id_}", "HTTP") + .with_request("GET", f"/{id_}") + .will_respond_with(200) + ) + + @classmethod + def _add_async_interaction(cls, pact: Pact, id_: int) -> None: + (pact.upon_receiving(f"Async message {id_}", "Async").with_body({"count": id_})) + + @classmethod + def _add_sync_interaction(cls, pact: Pact, id_: int) -> None: + ( + pact + .upon_receiving(f"Sync message {id_}", "Sync") + .with_body(f"request {id_}") + .will_respond_with() + .with_body(f"response {id_}") + ) + + @classmethod + def _add_interactions(cls, pact: Pact, id_: int) -> None: + cls._add_http_interaction(pact, id_) + cls._add_async_interaction(pact, id_) + cls._add_sync_interaction(pact, id_) + + @pytest.mark.parametrize( + "version", + ["1", "1.1", "2", "3", "4"], + ) + def test_pact_versions(self, pact: Pact, version: str) -> None: + pact.with_specification(version) + self._add_interactions(pact, 1) + + assert self._interaction_count(pact, "HTTP") == 1 + assert self._interaction_count(pact, "Async") == 1 + assert self._interaction_count(pact, "Sync") == 1 + assert self._interaction_count(pact, "All") == 3 + + @pytest.mark.parametrize( + ("version", "http", "async_", "sync"), + itertools.product(["1", "1.1", "2", "3", "4"], range(3), range(3), range(3)), + ) + def test_mixed_interactions( + self, + pact: Pact, + version: str, + http: int, + async_: int, + sync: int, + ) -> None: + pact.with_specification(version) + for i in range(http): + self._add_http_interaction(pact, i) + for i in range(async_): + self._add_async_interaction(pact, i) + for i in range(sync): + self._add_sync_interaction(pact, i) + + # Verify the expected counts + assert self._interaction_count(pact, "HTTP") == http + assert self._interaction_count(pact, "Async") == async_ + assert self._interaction_count(pact, "Sync") == sync + assert self._interaction_count(pact, "All") == (http + async_ + sync) + + # Verify repeated iteration works + assert self._interaction_count(pact, "HTTP") == http + assert self._interaction_count(pact, "Async") == async_ + assert self._interaction_count(pact, "Sync") == sync + assert self._interaction_count(pact, "All") == (http + async_ + sync) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 000000000..3526ac1d5 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,134 @@ +""" +Tests for `pact._server` module. +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import aiohttp +import pytest + +from pact._server import MessageProducer, StateCallback + + +def test_message_default_init() -> None: + handler = MagicMock() + server = MessageProducer(handler) + + assert server.host == "localhost" + assert server.port > 1024 # Random non-privileged port + assert server.url == f"http://{server.host}:{server.port}/_pact/message" + + +@pytest.mark.asyncio +async def test_message_invalid_path_http() -> None: + handler = MagicMock(return_value="Not OK") + server = MessageProducer(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.get(server.url) as response: + assert response.status == 404 + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_message_get_http() -> None: + handler = MagicMock(return_value=b"Pact Python is awesome!") + server = MessageProducer(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.get(server.url) as response: + assert response.status == 404 + + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_message_post_http() -> None: + handler = MagicMock( + return_value={ + "contents": json.dumps({"hello": "world"}).encode(), + "metadata": None, + "content_type": "application/json", + } + ) + server = MessageProducer(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.post( + server.url, + data=json.dumps({ + "description": "A simple message", + }), + ) as response: + assert response.status == 200 + assert await response.text() == '{"hello": "world"}' + + handler.assert_called_once() + assert handler.call_args.args == ("A simple message", {}) + + +def test_callback_default_init() -> None: + handler = MagicMock() + server = StateCallback(handler) + + assert server.host == "localhost" + assert server.port > 1024 # Random non-privileged port + assert server.url == f"http://{server.host}:{server.port}/_pact/state" + + +@pytest.mark.asyncio +async def test_callback_invalid_http() -> None: + handler = MagicMock(return_value=None) + server = StateCallback(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.get(server.url) as response: + assert response.status == 404 + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_callback_get_http() -> None: + handler = MagicMock(return_value=None) + server = StateCallback(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.get(server.url) as response: + assert response.status == 404 + + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_callback_post() -> None: + handler = MagicMock(return_value=None) + server = StateCallback(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.post( + server.url, + json={ + "state": "user exists", + "action": "setup", + "params": { + "id": 123, + }, + }, + ) as response: + assert response.status == 200 + + handler.assert_called_once() + assert handler.call_args.args == ( + "user exists", + "setup", + {"id": 123}, + ) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 000000000..370e36cf5 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,325 @@ +""" +Tests of pact._util functions. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +import pytest + +from pact._util import apply_args, strftime_to_simple_date_format + +if TYPE_CHECKING: + from collections.abc import Callable + + +def test_convert_python_to_java_datetime_format_basic() -> None: + assert strftime_to_simple_date_format("%Y-%m-%d") == "yyyy-MM-dd" + assert strftime_to_simple_date_format("%H:%M:%S") == "HH:mm:ss" + assert ( + strftime_to_simple_date_format("%Y-%m-%dT%H:%M:%S") == "yyyy-MM-dd'T'HH:mm:ss" + ) + + +def test_convert_python_to_java_datetime_format_with_unsupported_code() -> None: + with pytest.raises( + ValueError, + match="Cannot convert locale-dependent Python format code `%c` to Java", + ): + strftime_to_simple_date_format("%c") + + +def test_convert_python_to_java_datetime_format_with_warning() -> None: + with pytest.warns( + UserWarning, match="The Java equivalent for `%U` is locale dependent." + ): + assert strftime_to_simple_date_format("%U") == "ww" + + +def test_convert_python_to_java_datetime_format_with_escape_characters() -> None: + assert strftime_to_simple_date_format("'%Y-%m-%d'") == "''yyyy-MM-dd''" + assert strftime_to_simple_date_format("%%Y") == "%'Y'" + + +def test_convert_python_to_java_datetime_format_with_single_quote() -> None: + assert strftime_to_simple_date_format("%Y'%m'%d") == "yyyy''MM''dd" + + +class Args(NamedTuple): + """ + Named tuple to hold the arguments passed to a function. + """ + + args: dict[str, Any] + kwargs: dict[str, Any] + variadic_args: list[Any] + variadic_kwargs: dict[str, Any] + + +def no_annotations(a, b, c, d=b"d"): # noqa: ANN001, ANN201 # type: ignore[reportUnknownArgumentType] + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) + + +def annotated(a: int, b: str, c: float, d: bytes = b"d") -> Args: + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) + + +def mixed(a: int, /, b: str, *, c: float, d: bytes = b"d") -> Args: + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) + + +def variadic_args(*args: Any) -> Args: # noqa: ANN401 + return Args( + args={}, + kwargs={}, + variadic_args=list(args), + variadic_kwargs={}, + ) + + +def variadic_kwargs(**kwargs: Any) -> Args: # noqa: ANN401 + return Args( + args={}, + kwargs=kwargs, + variadic_args=[], + variadic_kwargs={**kwargs}, + ) + + +def variadic_args_kwargs(*args: Any, **kwargs: Any) -> Args: # noqa: ANN401 + return Args( + args={}, + kwargs=kwargs, + variadic_args=list(args), + variadic_kwargs={**kwargs}, + ) + + +def mixed_variadic_args(a: int, *args: Any, d: bytes = b"d") -> Args: # noqa: ANN401 + return Args( + args={"a": a, "d": d}, + kwargs={}, + variadic_args=list(args), + variadic_kwargs={}, + ) + + +def mixed_variadic_kwargs(a: int, d: bytes = b"d", **kwargs: Any) -> Args: # noqa: ANN401 + return Args( + args={"a": a, "d": d}, + kwargs=kwargs, + variadic_args=[], + variadic_kwargs={**kwargs}, + ) + + +def mixed_variadic_args_kwargs( + a: int, + *args: Any, # noqa: ANN401 + d: bytes = b"d", + **kwargs: Any, # noqa: ANN401 +) -> Args: + return Args( + args={"a": a, "d": d}, + kwargs=kwargs, + variadic_args=list(args), + variadic_kwargs={**kwargs}, + ) + + +class Foo: # noqa: D101 + def __init__(self) -> None: # noqa: D107 + pass + + def __call__(self, a: int, b: str, c: float, d: bytes = b"d") -> Args: + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) + + def method(self, a: int, b: str, c: float, d: bytes = b"d") -> Args: + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) + + @classmethod + def class_method(cls, a: int, b: str, c: float, d: bytes = b"d") -> Args: + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) + + @staticmethod + def static_method(a: int, b: str, c: float, d: bytes = b"d") -> Args: + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) + + +@pytest.mark.parametrize( + ("func", "args", "expected"), + [ + ( + no_annotations, + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), + ), + ( + no_annotations, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), + ), + ( + annotated, + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), + ), + ( + annotated, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), + ), + ( + mixed, + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), + ), + ( + mixed, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), + ), + ( + variadic_args, + {"a": 1, "b": "b", "c": 3.14}, + Args({}, {}, [1, "b", 3.14], {}), + ), + ( + variadic_args, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({}, {}, [1, "b", 3.14, b"e"], {}), + ), + ( + variadic_kwargs, + {"a": 1, "b": "b", "c": 3.14}, + Args({}, {"a": 1, "b": "b", "c": 3.14}, [], {"a": 1, "b": "b", "c": 3.14}), + ), + ( + variadic_kwargs, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args( + {}, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + [], + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + ), + ), + ( + variadic_args_kwargs, + {"a": 1, "b": "b", "c": 3.14}, + Args({}, {"a": 1, "b": "b", "c": 3.14}, [], {"a": 1, "b": "b", "c": 3.14}), + ), + ( + variadic_args_kwargs, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args( + {}, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + [], + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + ), + ), + ( + mixed_variadic_args, + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "d": b"d"}, {}, ["b", 3.14], {}), + ), + ( + mixed_variadic_args, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "d": b"e"}, {}, ["b", 3.14], {}), + ), + ( + mixed_variadic_kwargs, + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "d": b"d"}, {"b": "b", "c": 3.14}, [], {"b": "b", "c": 3.14}), + ), + ( + mixed_variadic_kwargs, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "d": b"e"}, {"b": "b", "c": 3.14}, [], {"b": "b", "c": 3.14}), + ), + ( + mixed_variadic_args_kwargs, + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "d": b"d"}, {"b": "b", "c": 3.14}, [], {"b": "b", "c": 3.14}), + ), + ( + mixed_variadic_args_kwargs, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "d": b"e"}, {"b": "b", "c": 3.14}, [], {"b": "b", "c": 3.14}), + ), + ( + Foo(), + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), + ), + ( + Foo(), + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), + ), + ( + Foo().class_method, + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), + ), + ( + Foo().class_method, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), + ), + ( + Foo().static_method, + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), + ), + ( + Foo().static_method, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), + ), + ], # type: ignore[reportUnknownArgumentType] +) +def test_apply_expected( + func: Callable[..., Args], + args: dict[str, Any], + expected: Args, +) -> None: + assert apply_args(func, args) == expected diff --git a/tests/test_verifier.py b/tests/test_verifier.py index 7b0a46f05..895b8b2e5 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -1,267 +1,286 @@ -from collections import OrderedDict +""" +Unit tests for the pact.verifier module. -from unittest import TestCase -import unittest -from mock import patch +These tests perform only very basic checks to ensure that the FFI module is +working correctly. They are not intended to test the Verifier API much, as +that is handled by the compatibility suite. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest from pact.verifier import Verifier -from pact.verify_wrapper import VerifyWrapper - - -def assertVerifyCalled(mock_wrapper, *pacts, **options): - tc = unittest.TestCase() - tc.assertEqual(mock_wrapper.call_count, 1) - - mock_wrapper.assert_called_once_with(*pacts, **options) - - -class VerifierPactsTestCase(TestCase): - - def setUp(self): - super(VerifierPactsTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.verifier = Verifier(provider='test_provider', - provider_base_url="http://localhost:8888") - - self.mock_wrapper = patch.object( - VerifyWrapper, 'call_verify').start() - - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch('pact.verifier.path_exists', return_value=True) - def test_verifier_with_provider_and_files(self, mock_path_exists, mock_wrapper): - mock_wrapper.return_value = (True, 'some logs') - - output, _ = self.verifier.verify_pacts('path/to/pact1', - 'path/to/pact2', - headers=['header1', 'header2']) - - assertVerifyCalled(mock_wrapper, - 'path/to/pact1', - 'path/to/pact2', - provider='test_provider', - custom_provider_headers=['header1', 'header2'], - provider_base_url='http://localhost:8888', - log_level='INFO', - verbose=False, - enable_pending=False, - include_wip_pacts_since=None) - - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch('pact.verifier.path_exists', return_value=True) - def test_verifier_with_provider_and_files_passes_consumer_selctors(self, mock_path_exists, mock_wrapper): - mock_wrapper.return_value = (True, 'some logs') - - output, _ = self.verifier.verify_pacts( - 'path/to/pact1', - 'path/to/pact2', - headers=['header1', 'header2'], - consumer_version_selectors=[ - # Using OrderedDict for the sake of testing - OrderedDict([("tag", "main"), ("latest", True)]), - OrderedDict([("tag", "test"), ("latest", False)]), - ] - ) - assertVerifyCalled(mock_wrapper, - 'path/to/pact1', - 'path/to/pact2', - provider='test_provider', - custom_provider_headers=['header1', 'header2'], - provider_base_url='http://localhost:8888', - log_level='INFO', - verbose=False, - enable_pending=False, - include_wip_pacts_since=None, - consumer_selectors=['{"tag": "main", "latest": true}', - '{"tag": "test", "latest": false}']) - - def test_validate_on_publish_results(self): - self.assertRaises(Exception, self.verifier.verify_pacts, 'path/to/pact1', publish=True) - - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch('pact.verifier.path_exists', return_value=True) - def test_publish_on_success(self, mock_path_exists, mock_wrapper): - mock_wrapper.return_value = (True, 'some logs') - - output, _ = self.verifier.verify_pacts('path/to/pact1', publish_version='1.0.0') - - assertVerifyCalled(mock_wrapper, - 'path/to/pact1', - provider='test_provider', - provider_base_url='http://localhost:8888', - log_level='INFO', - verbose=False, - provider_app_version='1.0.0', - enable_pending=False, - include_wip_pacts_since=None) - - @patch('pact.verifier.path_exists', return_value=False) - def test_raises_error_on_missing_pact_files(self, mock_path_exists): - self.assertRaises(Exception, - self.verifier.verify_pacts, - 'path/to/pact1', 'path/to/pact2') - - mock_path_exists.assert_called_with('path/to/pact2') - - @patch("pact.verify_wrapper.VerifyWrapper.call_verify", return_value=(0, None)) - @patch('pact.verifier.expand_directories', return_value=['./pacts/pact1', './pacts/pact2']) - @patch('pact.verifier.path_exists', return_value=True) - def test_expand_directories_called_for_pacts(self, mock_path_exists, mock_expand_dir, mock_wrapper): - output, _ = self.verifier.verify_pacts('path/to/pact1', - 'path/to/pact2') - - mock_expand_dir.assert_called_once() - - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) - def test_passes_enable_pending_flag_value(self, mock_wrapper): - for value in (True, False): - with self.subTest(value=value): - with patch('pact.verifier.path_exists'): - self.verifier.verify_pacts('any.json', enable_pending=value) - self.assertTrue( - ('enable_pending', value) in mock_wrapper.call_args.kwargs.items(), - mock_wrapper.call_args.kwargs, - ) - - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) - @patch('pact.verifier.path_exists', return_value=True) - def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): - self.verifier.verify_pacts('any.json', include_wip_pacts_since='2018-01-01') - self.assertTrue( - ('include_wip_pacts_since', '2018-01-01') in mock_wrapper.call_args.kwargs.items(), - mock_wrapper.call_args.kwargs, - ) +ASSETS_DIR = Path(__file__).parent / "assets" + + +@pytest.fixture +def verifier() -> Verifier: + return Verifier("Tester") + + +def test_str_repr(verifier: Verifier) -> None: + assert str(verifier) == "Verifier(Tester)" + assert re.match( + r"", + repr(verifier), + ) + + +def test_set_provider_info(verifier: Verifier) -> None: + url = "http://localhost:8888/api" + verifier.add_transport(url=url) + verifier.verify() + + +def test_add_provider_transport(verifier: Verifier) -> None: + # HTTP + verifier.add_transport( + protocol="http", + port=1234, + path="/api", + scheme="http", + ) + + # HTTPS + verifier.add_transport( + protocol="http", + port=4321, + path="/api", + scheme="https", + ) + + # message + verifier.add_transport( + protocol="message", + ) + + # gRPC + verifier.add_transport( + protocol="grpc", + port=1234, + ) + + +def test_set_filter(verifier: Verifier) -> None: + verifier.filter("test_filter") + verifier.filter("test_filter", state="test_value") + verifier.filter("no_state", no_state=True) + + +def test_set_state(verifier: Verifier) -> None: + verifier.state_handler("test_state", body=True) + + +def test_disable_ssl_verification(verifier: Verifier) -> None: + verifier.disable_ssl_verification() + + +def test_set_request_timeout(verifier: Verifier) -> None: + verifier.set_request_timeout(1000) + + +def test_set_coloured_output(verifier: Verifier) -> None: + verifier.set_coloured_output(enabled=True) + verifier.set_coloured_output(enabled=False) + + +def test_set_error_on_empty_pact(verifier: Verifier) -> None: + verifier.set_error_on_empty_pact(enabled=True) + verifier.set_error_on_empty_pact(enabled=False) -class VerifierBrokerTestCase(TestCase): - - def setUp(self): - super(VerifierBrokerTestCase, self).setUp() - self.addCleanup(patch.stopall) - self.verifier = Verifier(provider='test_provider', - provider_base_url="http://localhost:8888") - - self.mock_wrapper = patch.object( - VerifyWrapper, 'call_verify').start() - self.broker_username = 'broker_username' - self.broker_password = 'broker_password' - self.broker_url = 'http://broker' - - self.default_opts = { - 'broker_username': self.broker_username, - 'broker_password': self.broker_password, - 'broker_url': self.broker_url, - 'broker_token': 'token' - } - - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - def test_verifier_with_broker(self, mock_wrapper): - - mock_wrapper.return_value = (True, 'some value') - - output, _ = self.verifier.verify_with_broker(**self.default_opts) - - self.assertTrue(output) - assertVerifyCalled(mock_wrapper, - provider='test_provider', - provider_base_url='http://localhost:8888', - broker_password=self.broker_password, - broker_username=self.broker_username, - broker_token='token', - broker_url=self.broker_url, - log_level='INFO', - verbose=False, - enable_pending=False, - include_wip_pacts_since=None) - - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - def test_verifier_and_pubish_with_broker(self, mock_wrapper): - - mock_wrapper.return_value = (True, 'some value') - - self.default_opts['publish_version'] = '1.0.0' - output, _ = self.verifier.verify_with_broker(**self.default_opts) - - self.assertTrue(output) - assertVerifyCalled(mock_wrapper, - provider='test_provider', - provider_base_url='http://localhost:8888', - broker_password=self.broker_password, - broker_username=self.broker_username, - broker_token='token', - broker_url=self.broker_url, - log_level='INFO', - verbose=False, - enable_pending=False, - include_wip_pacts_since=None, - provider_app_version='1.0.0', - ) - - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - def test_verifier_with_broker_passes_consumer_selctors(self, mock_wrapper): - - mock_wrapper.return_value = (True, 'some value') - - output, _ = self.verifier.verify_with_broker( - consumer_version_selectors=[ - # Using OrderedDict for the sake of testing - OrderedDict([("tag", "main"), ("latest", True)]), - OrderedDict([("tag", "test"), ("latest", False)]), +def test_set_publish_options(verifier: Verifier) -> None: + verifier.set_publish_options( + version="1.0.0", + url="http://localhost:8080/build/1234", + branch="main", + tags=["main", "test", "prod"], + ) + + +def test_filter_consumers(verifier: Verifier) -> None: + verifier.filter_consumers("consumer1") + verifier.filter_consumers("consumer1", "consumer2") + + +def test_add_custom_header(verifier: Verifier) -> None: + verifier.add_custom_header("Authorization", "Bearer: 1234") + + +def test_add_custom_headers(verifier: Verifier) -> None: + verifier.add_custom_headers({ + "Authorization": "Bearer: 1234", + "Content-Type": "application/json", + }) + + +def test_add_source(verifier: Verifier) -> None: + # URL + verifier.add_source("http://localhost:8080/pact.json") + + # File + verifier.add_source(ASSETS_DIR / "pacts" / "basic.json") + + # Directory + verifier.add_source(ASSETS_DIR / "pacts") + + +def test_broker_source(verifier: Verifier) -> None: + verifier.broker_source("http://localhost:8080") + verifier.broker_source( + "http://localhost:8080", + username="user", + password="password", # noqa: S106 + ) + verifier.broker_source( + "http://localhost:8080", + token="1234", # noqa: S106 + ) + + +def test_broker_source_selector(verifier: Verifier) -> None: + ( + verifier + .broker_source("http://localhost:8080", selector=True) + .consumer_tags("main", "test") + .provider_tags("main", "test") + .consumer_versions('{"latest": true}') + .build() + ) + + +def test_verify(verifier: Verifier) -> None: + verifier.add_transport(url="http://localhost:8080") + verifier.verify() + + +def test_logs(verifier: Verifier) -> None: + logs = verifier.logs + assert logs == "" + + +def test_output(verifier: Verifier) -> None: + output = verifier.output() + assert output == "" + + +@pytest.mark.parametrize( + ("selector_calls", "expected_selectors"), + [ + pytest.param( + [{"consumer": "test-consumer"}], + [{"consumer": "test-consumer"}], + id="single_parameter", + ), + pytest.param( + [{"consumer": "test-consumer", "branch": "main", "latest": True}], + [{"consumer": "test-consumer", "branch": "main", "latest": True}], + id="multiple_parameters", + ), + pytest.param( + [{"deployed_or_released": True, "fallback_tag": "latest"}], + [{"deployedOrReleased": True, "fallbackTag": "latest"}], + id="camelcase_conversion", + ), + pytest.param( + [ + {"branch": "main", "latest": True}, + {"branch": "feature-branch", "latest": True}, + {"deployed": True}, + ], + [ + {"branch": "main", "latest": True}, + {"branch": "feature-branch", "latest": True}, + {"deployed": True}, + ], + id="multiple_selectors", + ), + pytest.param( + [ + { + "consumer": "test-consumer", + "tag": "v1.0", + "fallback_tag": "latest", + "latest": True, + "deployed_or_released": True, + "deployed": True, + "released": True, + "environment": "staging", + "main_branch": True, + "branch": "feature-123", + "matching_branch": True, + "fallback_branch": "develop", + } ], - **self.default_opts + [ + { + "consumer": "test-consumer", + "tag": "v1.0", + "fallbackTag": "latest", + "latest": True, + "deployedOrReleased": True, + "deployed": True, + "released": True, + "environment": "staging", + "mainBranch": True, + "branch": "feature-123", + "matchingBranch": True, + "fallbackBranch": "develop", + } + ], + id="all_parameters", + ), + pytest.param( + [ + { + "consumer": "test-consumer", + "branch": "main", + "tag": None, + "latest": None, + } + ], + [{"consumer": "test-consumer", "branch": "main"}], + id="none_values_excluded", + ), + ], +) +def test_consumer_version( + verifier: Verifier, + selector_calls: list[dict[str, Any]], + expected_selectors: list[dict[str, Any]], +) -> None: + """Test consumer_version with various parameter combinations and selector counts.""" + with patch("pact_ffi.verifier_broker_source_with_selectors") as mock_ffi: + selector_builder = verifier.broker_source( + "http://localhost:8080", + selector=True, ) - self.assertTrue(output) - assertVerifyCalled(mock_wrapper, - provider='test_provider', - provider_base_url='http://localhost:8888', - broker_password=self.broker_password, - broker_username=self.broker_username, - broker_token='token', - broker_url=self.broker_url, - log_level='INFO', - verbose=False, - enable_pending=False, - include_wip_pacts_since=None, - consumer_selectors=['{"tag": "main", "latest": true}', - '{"tag": "test", "latest": false}']) - - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch('pact.verifier.path_exists', return_value=True) - def test_publish_on_success(self, mock_path_exists, mock_wrapper): - mock_wrapper.return_value = (True, 'some logs') - - self.verifier.verify_with_broker(publish_version='1.0.0', **self.default_opts) - - assertVerifyCalled(mock_wrapper, - provider='test_provider', - provider_base_url='http://localhost:8888', - broker_password=self.broker_password, - broker_username=self.broker_username, - broker_token='token', - broker_url=self.broker_url, - log_level='INFO', - verbose=False, - provider_app_version='1.0.0', - enable_pending=False, - include_wip_pacts_since=None) - - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) - def test_passes_enable_pending_flag_value(self, mock_wrapper): - for value in (True, False): - with self.subTest(value=value): - with patch('pact.verifier.path_exists'): - self.verifier.verify_with_broker(enable_pending=value) - self.assertTrue( - ('enable_pending', value) in mock_wrapper.call_args.kwargs.items(), - mock_wrapper.call_args.kwargs, - ) - - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) - @patch('pact.verifier.path_exists', return_value=True) - def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): - self.verifier.verify_with_broker(include_wip_pacts_since='2018-01-01') - self.assertTrue( - ('include_wip_pacts_since', '2018-01-01') in mock_wrapper.call_args.kwargs.items(), - mock_wrapper.call_args.kwargs, - ) + # Call consumer_version for each set of parameters + for params in selector_calls: + selector_builder.consumer_version(**params) + + selector_builder.build() + # We call the hook explicitly to trigger the FFI call + assert verifier._broker_source_hook is not None # noqa: SLF001 + verifier._broker_source_hook() # noqa: SLF001 + + # Verify FFI was called with correct selectors + mock_ffi.assert_called_once() + selectors = [json.loads(s) for s in mock_ffi.call_args[0][9]] + + assert len(selectors) == len(expected_selectors) + for actual, expected in zip(selectors, expected_selectors, strict=True): + assert actual == expected + # For None value test case, verify excluded keys + if "tag" not in expected and "latest" not in expected: + assert "tag" not in actual + assert "latest" not in actual diff --git a/tests/test_xml.py b/tests/test_xml.py new file mode 100644 index 000000000..e16f1d293 --- /dev/null +++ b/tests/test_xml.py @@ -0,0 +1,222 @@ +""" +Unit tests for the :mod:`pact.xml` XML body builder. +""" + +from __future__ import annotations + +import json + +import pytest + +from pact import match, xml +from pact.match.matcher import AbstractMatcher, IntegrationJSONEncoder +from pact.xml import XmlElement, body, element + + +class TestElement: + """Tests for :func:`pact.xml.element`.""" + + def test_returns_xml_element(self) -> None: + result = element("id", 1) + assert isinstance(result, XmlElement) + + def test_literal_text_content(self) -> None: + result = body(element("id", 123)) + assert result == { + "root": { + "name": "id", + "children": [{"content": 123}], + "attributes": {}, + } + } + + def test_literal_string_content(self) -> None: + result = body(element("name", "Alice")) + assert result == { + "root": { + "name": "name", + "children": [{"content": "Alice"}], + "attributes": {}, + } + } + + def test_matcher_text_content_includes_content_and_matcher(self) -> None: + m = match.int(123) + result = body(element("id", m)) + children = result["root"]["children"] + assert len(children) == 1 + assert children[0]["content"] == 123 + assert children[0]["matcher"] is m + + def test_matcher_text_content_is_abstract_matcher(self) -> None: + result = body(element("id", match.int(123))) + matcher = result["root"]["children"][0]["matcher"] + assert isinstance(matcher, AbstractMatcher) + + def test_matcher_without_value_omits_content(self) -> None: + # match.int() with no value uses a generator + m = match.int() + result = body(element("id", m)) + children = result["root"]["children"] + assert "content" not in children[0] + assert children[0]["matcher"] is m + + def test_container_element_with_children(self) -> None: + result = body( + element( + "user", + element("id", 123), + element("name", "Alice"), + ) + ) + root = result["root"] + assert root["name"] == "user" + assert root["children"][0]["name"] == "id" + assert root["children"][1]["name"] == "name" + assert root["attributes"] == {} + + def test_empty_element(self) -> None: + result = body(element("empty")) + assert result == { + "root": { + "name": "empty", + "children": [], + "attributes": {}, + } + } + + def test_attrs_plain_values(self) -> None: + result = body(element("user", attrs={"id": "1", "version": "2"})) + assert result["root"]["attributes"] == {"id": "1", "version": "2"} + + def test_attrs_with_matcher(self) -> None: + m = match.int(1) + result = body(element("user", attrs={"id": m})) + assert result["root"]["attributes"]["id"] is m + + def test_attrs_none_gives_empty_dict(self) -> None: + result = body(element("tag")) + assert result["root"]["attributes"] == {} + + def test_namespace_declaration_in_attrs(self) -> None: + result = body( + element("ns1:projects", attrs={"xmlns:ns1": "http://example.com/"}) + ) + assert result["root"]["attributes"]["xmlns:ns1"] == "http://example.com/" + + +class TestEach: + """Tests for :meth:`XmlElement.each`.""" + + def test_each_returns_self(self) -> None: + elem = element("item", 1) + result = elem.each(min=1) + assert result is elem + + def test_each_wraps_element_in_type_matcher(self) -> None: + elem = element("item", element("id", 1)).each(min=1) + result = body(elem) + root = result["root"] + assert root["pact:matcher:type"] == "type" + assert root["min"] == 1 + assert root["value"]["name"] == "item" + + def test_each_default_examples_equals_min(self) -> None: + elem = element("item").each(min=3) + result = body(elem) + assert result["root"]["examples"] == 3 + + def test_each_explicit_examples(self) -> None: + elem = element("item").each(min=2, examples=5) + result = body(elem) + assert result["root"]["examples"] == 5 + assert result["root"]["min"] == 2 + + def test_each_with_max(self) -> None: + elem = element("item").each(min=1, max=10) + result = body(elem) + assert result["root"]["min"] == 1 + assert result["root"]["max"] == 10 + + def test_each_without_max_omits_max_key(self) -> None: + elem = element("item").each(min=1) + result = body(elem) + assert "max" not in result["root"] + + def test_each_raises_when_min_less_than_1(self) -> None: + with pytest.raises(ValueError, match="min must be at least 1"): + element("item").each(min=0) + + def test_each_raises_when_max_less_than_min(self) -> None: + with pytest.raises( + ValueError, match="max must be greater than or equal to min" + ): + element("item").each(min=3, max=2) + + def test_each_raises_when_examples_less_than_min(self) -> None: + with pytest.raises( + ValueError, match="examples must be greater than or equal to min" + ): + element("item").each(min=3, examples=2) + + def test_each_raises_when_examples_exceed_max(self) -> None: + with pytest.raises( + ValueError, match="examples must be less than or equal to max" + ): + element("item").each(min=1, max=3, examples=5) + + def test_each_as_child_wraps_in_type_matcher(self) -> None: + result = body( + element( + "items", + element("item", element("id", 1)).each(min=2), + ) + ) + child = result["root"]["children"][0] + assert child["pact:matcher:type"] == "type" + assert child["min"] == 2 + assert child["value"]["name"] == "item" + + +class TestBody: + """Tests for :func:`pact.xml.body`.""" + + def test_wraps_in_root_key(self) -> None: + result = body(element("foo")) + assert "root" in result + assert result["root"]["name"] == "foo" + + def test_returns_dict(self) -> None: + result = body(element("foo")) + assert isinstance(result, dict) + + +class TestIntegration: + """Integration: body() output survives json.dumps with IntegrationJSONEncoder.""" + + def test_json_serialises_matchers(self) -> None: + result = body( + element( + "user", + element("id", match.int(123)), + element("name", match.str("Alice")), + ) + ) + serialised = json.dumps(result, cls=IntegrationJSONEncoder) + parsed = json.loads(serialised) + + id_child = parsed["root"]["children"][0] + assert id_child["name"] == "id" + assert id_child["children"][0]["content"] == 123 + assert id_child["children"][0]["matcher"]["pact:matcher:type"] == "integer" + + name_child = parsed["root"]["children"][1] + assert name_child["children"][0]["matcher"]["pact:matcher:type"] == "type" + + def test_module_access_via_pact_xml(self) -> None: + result = xml.body(xml.element("user", xml.element("id", match.int(1)))) + assert result["root"]["name"] == "user" + + def test_direct_import(self) -> None: + result = body(element("user", element("id", 1))) + assert result["root"]["name"] == "user" diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/v2/cli/__init__.py b/tests/v2/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/test_verify.py b/tests/v2/cli/test_verify.py similarity index 90% rename from tests/cli/test_verify.py rename to tests/v2/cli/test_verify.py index 3c242a941..e543c4991 100644 --- a/tests/cli/test_verify.py +++ b/tests/v2/cli/test_verify.py @@ -4,7 +4,7 @@ from click.testing import CliRunner from mock import patch -from pact.cli import verify +from pact.v2.cli import verify from subprocess import PIPE, Popen @@ -69,7 +69,7 @@ def test_provider_base_url_is_required(self): self.assertEqual(result.exit_code, 2) self.assertIn('--provider-base-url', result.output) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") def test_pact_urls_or_broker_are_required(self, mock_wrapper): result = self.runner.invoke( verify.main, ['--provider-base-url=http://localhost']) @@ -78,7 +78,7 @@ def test_pact_urls_or_broker_are_required(self, mock_wrapper): self.assertIn('at least one', result.output) mock_wrapper.assert_not_called() - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") def test_broker_url_but_no_provider_required(self, mock_wrapper): result = self.runner.invoke( verify.main, ['--provider-base-url=http://localhost', @@ -87,8 +87,8 @@ def test_broker_url_but_no_provider_required(self, mock_wrapper): mock_wrapper.assert_not_called() self.assertEqual(result.exit_code, 1) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch("pact.verify_wrapper.isfile", return_value=True) + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.isfile", return_value=True) def test_wrapper_error_code_returned(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 8, None # rnd number to indicate retval returned @@ -97,8 +97,8 @@ def test_wrapper_error_code_returned(self, mock_isfile, mock_wrapper): self.assertFalse(mock_wrapper.call_verify.called) self.assertEqual(result.exit_code, 8) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch("pact.verify_wrapper.isfile", return_value=True) + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.isfile", return_value=True) def test_successful_verification(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None # rnd number to indicate retval returned @@ -115,8 +115,8 @@ def test_successful_verification(self, mock_isfile, mock_wrapper): publish_verification_results=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch("pact.verify_wrapper.isfile", return_value=True) + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.isfile", return_value=True) def test_broker_url_and_provider_required(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -125,8 +125,8 @@ def test_broker_url_and_provider_required(self, mock_isfile, mock_wrapper): mock_wrapper.assert_called() self.assertEqual(result.exit_code, 0) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch("pact.verify_wrapper.isfile", return_value=True) + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.isfile", return_value=True) def test_pact_url_param_supported(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None result = self.runner.invoke( @@ -146,8 +146,8 @@ def test_pact_url_param_supported(self, mock_isfile, mock_wrapper): include_wip_pacts_since=None) self.assertEqual(result.exit_code, 0) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch("pact.verify_wrapper.isfile", return_value=True) + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.isfile", return_value=True) def test_pact_urls_param_supported(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None result = self.runner.invoke(verify.main, [ @@ -169,8 +169,8 @@ def test_pact_urls_param_supported(self, mock_isfile, mock_wrapper): include_wip_pacts_since=None) self.assertEqual(result.exit_code, 0) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch("pact.verify_wrapper.isfile", return_value=False) + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.isfile", return_value=False) def test_local_pact_urls_must_exist(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -179,8 +179,8 @@ def test_local_pact_urls_must_exist(self, mock_isfile, mock_wrapper): self.assertIn('./pacts/consumer-provider.json', result.output) mock_wrapper.call_verify.assert_not_called - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch("pact.verify_wrapper.isfile", return_value=True) + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.isfile", return_value=True) def test_failed_verification(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 3, None result = self.runner.invoke(verify.main, self.simple_pact_opts) @@ -198,8 +198,8 @@ def test_failed_verification(self, mock_isfile, mock_wrapper): @patch.dict(os.environ, {'PACT_BROKER_PASSWORD': 'pwd', 'PACT_BROKER_USERNAME': 'broker_user', 'PACT_BROKER_BASE_URL': 'http://broker/'}) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch("pact.verify_wrapper.isfile", return_value=True) + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.isfile", return_value=True) def test_broker_creds_from_env_var(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -219,8 +219,8 @@ def test_broker_creds_from_env_var(self, mock_isfile, mock_wrapper): publish_verification_results=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") - @patch("pact.verify_wrapper.isfile", return_value=True) + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.isfile", return_value=True) def test_all_url_options(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None result = self.runner.invoke(verify.main, [ @@ -270,7 +270,7 @@ def test_all_url_options(self, mock_isfile, mock_wrapper): publish_verification_results=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") def test_all_broker_options(self, mock_wrapper): mock_wrapper.return_value = 0, None result = self.runner.invoke(verify.main, [ @@ -319,7 +319,7 @@ def test_all_broker_options(self, mock_wrapper): include_wip_pacts_since='2018-01-01', provider_version_branch='provider-branch') - @patch("pact.verify_wrapper.isfile", return_value=True) + @patch("pact.v2.verify_wrapper.isfile", return_value=True) def test_publishing_missing_version(self, mock_isfile): result = self.runner.invoke(verify.main, [ '--pact-urls=./pacts/consumer-provider.json', @@ -330,7 +330,7 @@ def test_publishing_missing_version(self, mock_isfile): self.assertIn( 'Provider application version is required', result.output) - @patch('pact.cli.verify.path_exists', return_value=True) + @patch('pact.v2.cli.verify.path_exists', return_value=True) def test_file_does_not_exist_errors(self, mock_path_exists): mock_path_exists.return_value = False result = self.runner.invoke(verify.main, [ @@ -347,8 +347,8 @@ def test_file_does_not_exist_errors(self, mock_path_exists): mock_path_exists.assert_called_once_with( './pacts/consumer-provider.json') - @patch('pact.cli.verify.path_exists', return_value=True) - @patch('pact.cli.verify.expand_directories', return_value='./pacts/consumer-provider.json') + @patch('pact.v2.cli.verify.path_exists', return_value=True) + @patch('pact.v2.cli.verify.expand_directories', return_value='./pacts/consumer-provider.json') def test_expand_directories_called(self, mock_expand_dirs, mock_path_exists): mock_expand_dirs.return_value = ['foo'] diff --git a/tests/test_broker.py b/tests/v2/test_broker.py similarity index 90% rename from tests/test_broker.py rename to tests/v2/test_broker.py index 0cbdc3566..ab5bf2435 100644 --- a/tests/test_broker.py +++ b/tests/v2/test_broker.py @@ -1,12 +1,13 @@ import os from unittest import TestCase +from unittest.mock import ANY from mock import patch -from pact.broker import Broker -from pact.consumer import Consumer, Provider -from pact.constants import BROKER_CLIENT_PATH -from pact import broker as broker +from pact.v2.broker import Broker +from pact.v2.consumer import Consumer, Provider +from pact_cli import BROKER_CLIENT_PATH +from pact.v2 import broker as broker class BrokerTestCase(TestCase): @@ -48,7 +49,7 @@ def test_publish_fails(self): '--broker-username=username', '--broker-password=password', '--broker-token=token', - './TestConsumer-TestProvider.json']) + './TestConsumer-TestProvider.json'], env=ANY) def test_publish_with_broker_url_environment_variable(self): BROKER_URL_ENV = 'http://broker.url' @@ -67,7 +68,7 @@ def test_publish_with_broker_url_environment_variable(self): f"--broker-base-url={BROKER_URL_ENV}", '--broker-username=username', '--broker-password=password', - './TestConsumer-TestProvider.json']) + './TestConsumer-TestProvider.json'], env=ANY) del os.environ["PACT_BROKER_BASE_URL"] @@ -86,7 +87,7 @@ def test_basic_authenticated_publish(self): '--broker-base-url=http://localhost', '--broker-username=username', '--broker-password=password', - './TestConsumer-TestProvider.json']) + './TestConsumer-TestProvider.json'], env=ANY) def test_token_authenticated_publish(self): broker = Broker(broker_base_url="http://localhost", @@ -105,7 +106,7 @@ def test_token_authenticated_publish(self): '--broker-username=username', '--broker-password=password', '--broker-token=token', - './TestConsumer-TestProvider.json']) + './TestConsumer-TestProvider.json'], env=ANY) def test_git_tagged_publish(self): broker = Broker(broker_base_url="http://localhost") @@ -120,7 +121,7 @@ def test_git_tagged_publish(self): '--consumer-app-version=2.0.1', '--broker-base-url=http://localhost', './TestConsumer-TestProvider.json', - '--tag-with-git-branch']) + '--tag-with-git-branch'], env=ANY) def test_manual_tagged_publish(self): broker = Broker(broker_base_url="http://localhost") @@ -136,7 +137,7 @@ def test_manual_tagged_publish(self): '--broker-base-url=http://localhost', './TestConsumer-TestProvider.json', '-t', 'tag1', - '-t', 'tag2']) + '-t', 'tag2'], env=ANY) def test_branch_publish(self): broker = Broker(broker_base_url="http://localhost") @@ -151,7 +152,7 @@ def test_branch_publish(self): '--consumer-app-version=2.0.1', '--broker-base-url=http://localhost', './TestConsumer-TestProvider.json', - '--branch=consumer-branch']) + '--branch=consumer-branch'], env=ANY) def test_build_url_publish(self): broker = Broker(broker_base_url="http://localhost") @@ -166,7 +167,7 @@ def test_build_url_publish(self): '--consumer-app-version=2.0.1', '--broker-base-url=http://localhost', './TestConsumer-TestProvider.json', - '--build-url=http://ci']) + '--build-url=http://ci'], env=ANY) def test_auto_detect_version_properties_publish(self): broker = Broker(broker_base_url="http://localhost") @@ -181,4 +182,4 @@ def test_auto_detect_version_properties_publish(self): '--consumer-app-version=2.0.1', '--broker-base-url=http://localhost', './TestConsumer-TestProvider.json', - '--auto-detect-version-properties']) + '--auto-detect-version-properties'], env=ANY) diff --git a/tests/v2/test_constants.py b/tests/v2/test_constants.py new file mode 100644 index 000000000..ed2008869 --- /dev/null +++ b/tests/v2/test_constants.py @@ -0,0 +1,51 @@ +"""Test the values in pact.constants.""" + +import os + + +def test_broker_client() -> None: + """Test the value of BROKER_CLIENT_PATH on POSIX.""" + import pact.v2.constants + + if os.name == "nt": + # As the Windows filesystem is case insensitive, we must normalize it. + assert pact.v2.constants.BROKER_CLIENT_PATH.lower().endswith("pact-broker.bat") + else: + assert pact.v2.constants.BROKER_CLIENT_PATH.endswith("pact-broker") + + +def test_message() -> None: + """Test the value of MESSAGE_PATH on POSIX.""" + import pact.v2.constants + + if os.name == "nt": + # As the Windows filesystem is case insensitive, we must normalize it. + assert pact.v2.constants.MESSAGE_PATH.lower().endswith("pact-message.bat") + else: + assert pact.v2.constants.MESSAGE_PATH.endswith("pact-message") + + +def test_mock_service() -> None: + """Test the value of MOCK_SERVICE_PATH on POSIX.""" + import pact.v2.constants + + if os.name == "nt": + # As the Windows filesystem is case insensitive, we must normalize it. + assert pact.v2.constants.MOCK_SERVICE_PATH.lower().endswith( + "pact-mock-service.bat", + ) + else: + assert pact.v2.constants.MOCK_SERVICE_PATH.endswith("pact-mock-service") + + +def test_verifier() -> None: + """Test the value of VERIFIER_PATH on POSIX.""" + import pact.v2.constants + + if os.name == "nt": + # As the Windows filesystem is case insensitive, we must normalize it. + assert pact.v2.constants.VERIFIER_PATH.lower().endswith( + "pact-provider-verifier.bat", + ) + else: + assert pact.v2.constants.VERIFIER_PATH.endswith("pact-provider-verifier") diff --git a/tests/test_consumer.py b/tests/v2/test_consumer.py similarity index 95% rename from tests/test_consumer.py rename to tests/v2/test_consumer.py index d0fcfbe82..89576b1d0 100644 --- a/tests/test_consumer.py +++ b/tests/v2/test_consumer.py @@ -2,9 +2,9 @@ from mock import Mock -from pact.consumer import Consumer -from pact.provider import Provider -from pact.pact import Pact +from pact.v2.consumer import Consumer +from pact.v2.provider import Provider +from pact.v2.pact import Pact class ConsumerTestCase(TestCase): diff --git a/tests/test_http_proxy.py b/tests/v2/test_http_proxy.py similarity index 98% rename from tests/test_http_proxy.py rename to tests/v2/test_http_proxy.py index 36bac25a8..b372c2141 100644 --- a/tests/test_http_proxy.py +++ b/tests/v2/test_http_proxy.py @@ -1,5 +1,5 @@ from unittest import TestCase -from pact.http_proxy import app +from pact.v2.http_proxy import app from fastapi.testclient import TestClient client = TestClient(app) diff --git a/tests/test_matchers.py b/tests/v2/test_matchers.py similarity index 89% rename from tests/test_matchers.py rename to tests/v2/test_matchers.py index 1cc446f3b..d82aca428 100644 --- a/tests/test_matchers.py +++ b/tests/v2/test_matchers.py @@ -2,7 +2,7 @@ from unittest import TestCase -from pact.matchers import EachLike, Like, Matcher, SomethingLike, \ +from pact.v2.matchers import EachLike, Like, Matcher, SomethingLike, \ Term, Format, from_term, get_generated_values @@ -407,3 +407,45 @@ def test_time(self): }, }, ) + + def test_iso_8601_datetime(self): + date = self.formatter.iso_datetime.generate() + self.assertEqual( + date, + { + "json_class": "Pact::Term", + "json_class": "Pact::Term", + "data": { + "matcher": { + "json_class": "Regexp", + "s": self.formatter.Regexes.iso_8601_datetime.value, + "o": 0, + }, + "generate": datetime.datetime( + 1991, 2, 20, 6, 35, 26, + tzinfo=datetime.timezone.utc + ).isoformat(), + }, + }, + ) + + def test_iso_8601_datetime_mills(self): + date = self.formatter.iso_datetime_ms.generate() + self.assertEqual( + date, + { + "json_class": "Pact::Term", + "json_class": "Pact::Term", + "data": { + "matcher": { + "json_class": "Regexp", + "s": self.formatter.Regexes.iso_8601_datetime_ms.value, + "o": 0, + }, + "generate": datetime.datetime( + 1991, 2, 20, 6, 35, 26, 79043, + tzinfo=datetime.timezone.utc + ).isoformat(), + }, + }, + ) diff --git a/tests/test_message_consumer.py b/tests/v2/test_message_consumer.py similarity index 93% rename from tests/test_message_consumer.py rename to tests/v2/test_message_consumer.py index 518980e7b..c381ed3cc 100644 --- a/tests/test_message_consumer.py +++ b/tests/v2/test_message_consumer.py @@ -2,9 +2,9 @@ from mock import Mock -from pact.message_consumer import MessageConsumer -from pact.provider import Provider -from pact.message_pact import MessagePact +from pact.v2.message_consumer import MessageConsumer +from pact.v2.provider import Provider +from pact.v2.message_pact import MessagePact class MessageConsumerTestCase(TestCase): diff --git a/tests/test_message_pact.py b/tests/v2/test_message_pact.py similarity index 97% rename from tests/test_message_pact.py rename to tests/v2/test_message_pact.py index 6244735d8..ef35b011c 100644 --- a/tests/test_message_pact.py +++ b/tests/v2/test_message_pact.py @@ -1,14 +1,15 @@ import os import json +from unittest import TestCase +from unittest.mock import ANY from mock import patch -from unittest import TestCase -from pact.message_consumer import MessageConsumer, Provider -from pact.message_pact import MessagePact -from pact.constants import MESSAGE_PATH -from pact import message_pact as message_pact -from pact import Term +from pact.v2.message_consumer import MessageConsumer, Provider +from pact.v2.message_pact import MessagePact +from pact_cli import MESSAGE_PATH +from pact.v2 import message_pact as message_pact +from pact.v2 import Term class MessagePactTestCase(TestCase): def setUp(self): @@ -264,4 +265,4 @@ def test_call_pact_message_to_generate_pact_file(self): '--pact-specification-version=3.0.0', '--consumer', 'TestConsumer', '--provider', 'TestProvider', - ]) + ], env=ANY) diff --git a/tests/test_message_provider.py b/tests/v2/test_message_provider.py similarity index 87% rename from tests/test_message_provider.py rename to tests/v2/test_message_provider.py index 8b71c3bdd..13391abc7 100644 --- a/tests/test_message_provider.py +++ b/tests/v2/test_message_provider.py @@ -3,8 +3,8 @@ from mock import patch, Mock from unittest import TestCase -from pact.message_provider import MessageProvider -from pact import message_provider as message_provider +from pact.v2.message_provider import MessageProvider +from pact.v2 import message_provider as message_provider class MessageProviderTestCase(TestCase): @@ -51,14 +51,14 @@ def test_init(self): self.assertEqual(self.provider.proxy_host, 'localhost') self.assertEqual(self.provider.proxy_port, '1234') - @patch('pact.Verifier.verify_pacts', return_value=(0, 'logs')) + @patch('pact.v2.Verifier.verify_pacts', return_value=(0, 'logs')) def test_verify(self, mock_verify_pacts): self.provider.verify() assert mock_verify_pacts.call_count == 1 mock_verify_pacts.assert_called_with(f'{self.provider.pact_dir}/{self.provider._pact_file()}', verbose=False) - @patch('pact.Verifier.verify_with_broker', return_value=(0, 'logs')) + @patch('pact.v2.Verifier.verify_with_broker', return_value=(0, 'logs')) def test_verify_with_broker(self, mock_verify_pacts): self.provider.verify_with_broker(**self.options) @@ -74,8 +74,8 @@ class MessageProviderContextManagerTestCase(MessageProviderTestCase): def setUp(self): super(MessageProviderContextManagerTestCase, self).setUp() - @patch('pact.MessageProvider._start_proxy', return_value=0) - @patch('pact.MessageProvider._stop_proxy', return_value=0) + @patch('pact.v2.message_provider.MessageProvider._start_proxy', return_value=0) + @patch('pact.v2.message_provider.MessageProvider._stop_proxy', return_value=0) def test_successful(self, mock_stop_proxy, mock_start_proxy): with self.provider: pass @@ -83,9 +83,9 @@ def test_successful(self, mock_stop_proxy, mock_start_proxy): mock_start_proxy.assert_called_once() mock_stop_proxy.assert_called_once() - @patch('pact.MessageProvider._wait_for_server_start', side_effect=RuntimeError('boom!')) - @patch('pact.MessageProvider._start_proxy', return_value=0) - @patch('pact.MessageProvider._stop_proxy', return_value=0) + @patch('pact.v2.message_provider.MessageProvider._wait_for_server_start', side_effect=RuntimeError('boom!')) + @patch('pact.v2.message_provider.MessageProvider._start_proxy', return_value=0) + @patch('pact.v2.message_provider.MessageProvider._stop_proxy', return_value=0) def test_stop_proxy_on_runtime_error(self, mock_stop_proxy, mock_start_proxy, mock_wait_for_server_start,): with self.provider: pass @@ -133,7 +133,7 @@ def setUp(self): @patch.object(message_provider.requests, 'Session') @patch.object(message_provider, 'Retry') @patch.object(message_provider, 'HTTPAdapter') - @patch('pact.MessageProvider._stop_proxy') + @patch('pact.v2.message_provider.MessageProvider._stop_proxy') def test_wait_for_server_start_success(self, mock_stop_proxy, mock_HTTPAdapter, mock_Retry, mock_Session): mock_Session.return_value.get.return_value.status_code = 200 self.provider._wait_for_server_start() @@ -150,7 +150,7 @@ def test_wait_for_server_start_success(self, mock_stop_proxy, mock_HTTPAdapter, @patch.object(message_provider.requests, 'Session') @patch.object(message_provider, 'Retry') @patch.object(message_provider, 'HTTPAdapter') - @patch('pact.MessageProvider._stop_proxy') + @patch('pact.v2.message_provider.MessageProvider._stop_proxy') def test_wait_for_server_start_failure(self, mock_stop_proxy, mock_HTTPAdapter, mock_Retry, mock_Session): mock_Session.return_value.get.return_value.status_code = 500 diff --git a/tests/v2/test_pact.py b/tests/v2/test_pact.py new file mode 100644 index 000000000..27ae88d27 --- /dev/null +++ b/tests/v2/test_pact.py @@ -0,0 +1,664 @@ +import os +from subprocess import Popen +from unittest import TestCase +from unittest.mock import ANY + +from mock import patch, call, Mock +from psutil import Process + +from pact.v2.broker import Broker +from pact.v2.consumer import Consumer, Provider +from pact.v2.matchers import Term +from pact_cli import MOCK_SERVICE_PATH +from pact.v2.pact import Pact, FromTerms, Request, Response +from pact.v2 import pact as pact +from pact.v2.verify_wrapper import PactException + + +class PactTestCase(TestCase): + def setUp(self): + self.consumer = Consumer('TestConsumer') + self.provider = Provider('TestProvider') + + def test_init_defaults(self): + target = Pact(self.consumer, self.provider) + self.assertIs(target.broker_base_url, None) + self.assertIs(target.broker_username, None) + self.assertIs(target.broker_password, None) + self.assertIs(target.consumer, self.consumer) + self.assertIs(target.cors, False) + self.assertEqual(target.host_name, 'localhost') + self.assertEqual(target.log_dir, os.getcwd()) + self.assertEqual(target.pact_dir, os.getcwd()) + self.assertEqual(target.port, 1234) + self.assertIs(target.provider, self.provider) + self.assertIs(target.publish_to_broker, False) + self.assertIs(target.ssl, False) + self.assertIsNone(target.sslcert) + self.assertIsNone(target.sslkey) + self.assertEqual(target.uri, 'http://localhost:1234') + self.assertEqual(target.specification_version, '2.0.0') + self.assertEqual(len(target._interactions), 0) + + def test_init_custom_mock_service(self): + target = Pact( + self.consumer, self.provider, host_name='192.168.1.1', port=8000, + log_dir='/logs', ssl=True, sslcert='/ssl.cert', sslkey='/ssl.pem', + cors=True, pact_dir='/pacts', specification_version='3.0.0', + file_write_mode='merge') + + self.assertIs(target.consumer, self.consumer) + self.assertIs(target.cors, True) + self.assertEqual(target.host_name, '192.168.1.1') + self.assertEqual(target.log_dir, '/logs') + self.assertEqual(target.pact_dir, '/pacts') + self.assertEqual(target.port, 8000) + self.assertIs(target.provider, self.provider) + self.assertIs(target.ssl, True) + self.assertEqual(target.sslcert, '/ssl.cert') + self.assertEqual(target.sslkey, '/ssl.pem') + self.assertEqual(target.uri, 'https://192.168.1.1:8000') + self.assertEqual(target.specification_version, '3.0.0') + self.assertEqual(target.file_write_mode, 'merge') + self.assertEqual(len(target._interactions), 0) + + def test_init_publish_to_broker(self): + target = Pact( + self.consumer, self.provider, publish_to_broker=True, + broker_base_url='http://localhost', broker_username='username', + broker_password='password', broker_token='token') + + self.assertEqual(target.broker_base_url, 'http://localhost') + self.assertEqual(target.broker_username, 'username') + self.assertEqual(target.broker_password, 'password') + self.assertEqual(target.broker_token, 'token') + self.assertIs(target.publish_to_broker, True) + + def test_definition_sparse(self): + target = Pact(self.consumer, self.provider) + (target + .given('I am creating a new pact using the Pact class') + .upon_receiving('a specific request to the server') + .with_request('GET', '/path') + .will_respond_with(200, body='success')) + + self.assertEqual(len(target._interactions), 1) + + self.assertEqual( + target._interactions[0]['provider_state'], + 'I am creating a new pact using the Pact class') + + self.assertEqual( + target._interactions[0]['description'], + 'a specific request to the server') + + self.assertEqual(target._interactions[0]['request'], + {'path': '/path', 'method': 'GET'}) + self.assertEqual(target._interactions[0]['response'], + {'status': 200, 'body': 'success'}) + + def test_definition_without_given(self): + target = Pact(self.consumer, self.provider) + (target + .upon_receiving('a specific request to the server') + .with_request('GET', '/path') + .will_respond_with(200, body='success')) + + self.assertEqual(len(target._interactions), 1) + + self.assertIsNone( + target._interactions[0].get('provider_state')) + + self.assertEqual( + target._interactions[0]['description'], + 'a specific request to the server') + + self.assertEqual(target._interactions[0]['request'], + {'path': '/path', 'method': 'GET'}) + self.assertEqual(target._interactions[0]['response'], + {'status': 200, 'body': 'success'}) + + def test_definition_all_options(self): + target = Pact(self.consumer, self.provider) + (target + .given('I am creating a new pact using the Pact class') + .upon_receiving('a specific request to the server') + .with_request('GET', '/path', + body={'key': 'value'}, + headers={'Accept': 'application/json'}, + query={'search': 'test'}) + .will_respond_with( + 200, + body='success', headers={'Content-Type': 'application/json'})) + + self.assertEqual( + target._interactions[0]['provider_state'], + 'I am creating a new pact using the Pact class') + + self.assertEqual( + target._interactions[0]['description'], + 'a specific request to the server') + + self.assertEqual(target._interactions[0]['request'], { + 'path': '/path', + 'method': 'GET', + 'body': {'key': 'value'}, + 'headers': {'Accept': 'application/json'}, + 'query': {'search': 'test'}}) + self.assertEqual(target._interactions[0]['response'], { + 'status': 200, + 'body': 'success', + 'headers': {'Content-Type': 'application/json'}}) + + def test_definition_multiple_interactions(self): + target = Pact(self.consumer, self.provider) + (target + .given('I am creating a new pact using the Pact class') + .upon_receiving('a specific request to the server') + .with_request('GET', '/foo') + .will_respond_with(200, body='success') + .given('I am creating another new pact using the Pact class') + .upon_receiving('a different request to the server') + .with_request('GET', '/bar') + .will_respond_with(200, body='success')) + + self.assertEqual(len(target._interactions), 2) + + self.assertEqual( + target._interactions[1]['provider_state'], + 'I am creating a new pact using the Pact class') + self.assertEqual( + target._interactions[0]['provider_state'], + 'I am creating another new pact using the Pact class') + + self.assertEqual( + target._interactions[1]['description'], + 'a specific request to the server') + self.assertEqual( + target._interactions[0]['description'], + 'a different request to the server') + + self.assertEqual(target._interactions[1]['request'], + {'path': '/foo', 'method': 'GET'}) + self.assertEqual(target._interactions[0]['request'], + {'path': '/bar', 'method': 'GET'}) + + self.assertEqual(target._interactions[1]['response'], + {'status': 200, 'body': 'success'}) + self.assertEqual(target._interactions[0]['response'], + {'status': 200, 'body': 'success'}) + + def test_definition_multiple_interactions_without_given(self): + target = Pact(self.consumer, self.provider) + (target + .upon_receiving('a specific request to the server') + .with_request('GET', '/foo') + .will_respond_with(200, body='success') + .upon_receiving('a different request to the server') + .with_request('GET', '/bar') + .will_respond_with(200, body='success')) + + self.assertEqual(len(target._interactions), 2) + + self.assertIsNone( + target._interactions[1].get('provider_state')) + self.assertIsNone( + target._interactions[0].get('provider_state')) + + self.assertEqual( + target._interactions[1]['description'], + 'a specific request to the server') + self.assertEqual( + target._interactions[0]['description'], + 'a different request to the server') + + self.assertEqual(target._interactions[1]['request'], + {'path': '/foo', 'method': 'GET'}) + self.assertEqual(target._interactions[0]['request'], + {'path': '/bar', 'method': 'GET'}) + + self.assertEqual(target._interactions[1]['response'], + {'status': 200, 'body': 'success'}) + self.assertEqual(target._interactions[0]['response'], + {'status': 200, 'body': 'success'}) + +class PactSetupTestCase(PactTestCase): + def setUp(self): + super(PactSetupTestCase, self).setUp() + self.addCleanup(patch.stopall) + self.mock_requests = patch('requests.api.request').start() + self.target = Pact(self.consumer, self.provider) + (self.target + .given('I am creating a new pact using the Pact class') + .upon_receiving('a specific request to the server') + .with_request('GET', '/path') + .will_respond_with(200, body='success')) + + self.delete_call = call('delete', 'http://localhost:1234/interactions', + headers={'X-Pact-Mock-Service': 'true'}, + verify=False) + + self.put_interactions_call = call( + 'put', 'http://localhost:1234/interactions', + data=None, + headers={'X-Pact-Mock-Service': 'true'}, + verify=False, + json={'interactions': [{ + 'response': {'status': 200, 'body': 'success'}, + 'request': {'path': '/path', 'method': 'GET'}, + 'description': 'a specific request to the server', + 'provider_state': 'I am creating a new pact using the ' + 'Pact class'}]}) + + def test_error_deleting_interactions(self): + self.mock_requests.side_effect = iter([ + Mock(status_code=500, text='deletion error')]) + + with self.assertRaises(AssertionError) as e: + self.target.setup() + + self.assertEqual(str(e.exception), 'deletion error') + self.assertEqual(self.mock_requests.call_count, 1) + self.mock_requests.assert_has_calls([self.delete_call]) + + def test_error_posting_interactions(self): + self.mock_requests.side_effect = iter([ + Mock(status_code=200), + Mock(status_code=500, text='post interactions error')]) + + with self.assertRaises(AssertionError) as e: + self.target.setup() + + self.assertEqual(str(e.exception), 'post interactions error') + self.assertEqual(self.mock_requests.call_count, 2) + self.mock_requests.assert_has_calls( + [self.delete_call, self.put_interactions_call]) + + def test_successful(self): + self.mock_requests.side_effect = iter([Mock(status_code=200)] * 4) + self.target.setup() + + self.assertEqual(self.mock_requests.call_count, 2) + self.mock_requests.assert_has_calls([ + self.delete_call, self.put_interactions_call]) + + +class PactStartShutdownServerTestCase(TestCase): + def setUp(self): + super(PactStartShutdownServerTestCase, self).setUp() + self.addCleanup(patch.stopall) + self.mock_Popen = patch.object(pact, 'Popen', autospec=True).start() + self.mock_Popen.return_value.returncode = 0 + self.mock_Process = patch.object( + pact.psutil, 'Process', autospec=True).start() + self.mock_platform = patch.object( + pact.platform, 'platform', autospec=True).start() + self.mock_wait_for_server_start = patch.object( + pact.Pact, '_wait_for_server_start', autospec=True).start() + self.mock_Pid_exists = patch.object( + pact.psutil, 'pid_exists', autospec=True).start() + self.mock_publish = patch.object( + Broker, 'publish', autospec=True).start() + + def test_start_fails(self): + self.mock_Popen.return_value.returncode = 1 + self.mock_wait_for_server_start.side_effect = RuntimeError + pact = Pact(Consumer('consumer'), Provider('provider'), + log_dir='/logs', pact_dir='/pacts') + + with self.assertRaises(RuntimeError): + pact.start_service() + + self.mock_Popen.assert_called_once_with([ + MOCK_SERVICE_PATH, 'service', + '--host=localhost', + '--port=1234', + '--log', '/logs/pact-mock-service.log', + '--pact-dir', '/pacts', + '--pact-file-write-mode', 'overwrite', + '--pact-specification-version=2.0.0', + '--consumer', 'consumer', + '--provider', 'provider'], env=ANY) + + def test_start_no_ssl(self): + pact = Pact(Consumer('consumer'), Provider('provider'), + log_dir='/logs', pact_dir='/pacts') + pact.start_service() + + self.mock_Popen.assert_called_once_with([ + MOCK_SERVICE_PATH, 'service', + '--host=localhost', + '--port=1234', + '--log', '/logs/pact-mock-service.log', + '--pact-dir', '/pacts', + '--pact-file-write-mode', 'overwrite', + '--pact-specification-version=2.0.0', + '--consumer', 'consumer', + '--provider', 'provider'], env=ANY) + + def test_start_with_ssl(self): + pact = Pact(Consumer('consumer'), Provider('provider'), + log_dir='/logs', pact_dir='/pacts', + ssl=True, sslcert='/ssl.cert', sslkey='/ssl.key') + pact.start_service() + + self.mock_Popen.assert_called_once_with([ + MOCK_SERVICE_PATH, 'service', + '--host=localhost', + '--port=1234', + '--log', '/logs/pact-mock-service.log', + '--pact-dir', '/pacts', + '--pact-file-write-mode', 'overwrite', + '--pact-specification-version=2.0.0', + '--consumer', 'consumer', + '--provider', 'provider', + '--ssl', + '--sslcert', '/ssl.cert', + '--sslkey', '/ssl.key'], env=ANY) + + def test_stop_posix(self): + self.mock_publish.return_value.returncode = 0 + self.mock_platform.return_value = 'Linux' + pact = Pact(Consumer('consumer'), Provider('provider')) + pact._process = Mock(spec=Popen, pid=999, returncode=0) + pact.stop_service() + + pact._process.terminate.assert_called_once_with() + pact._process.communicate.assert_called_once_with() + self.mock_publish.assert_not_called() + self.assertFalse(self.mock_Process.called) + + def test_stop_windows(self): + self.mock_platform.return_value = 'Windows' + ruby_exe = Mock(spec=Process) + self.mock_Process.return_value.children.return_value = [ruby_exe] + self.mock_Pid_exists.return_value = False + pact = Pact(Consumer('consumer', version='abc'), Provider('provider'), publish_to_broker=True, pact_dir='some_dir') + pact._process = Mock(spec=Popen, pid=999) + pact.stop_service() + + self.assertFalse(pact._process.terminate.called) + self.assertFalse(pact._process.communicate.called) + self.mock_Process.assert_called_once_with(999) + self.mock_Process.return_value.children.assert_called_once_with( + recursive=True) + ruby_exe.terminate.assert_called_once_with() + self.mock_Process.return_value.wait.assert_called_once_with() + self.mock_Pid_exists.assert_called_once_with(999) + self.mock_publish.assert_called_once_with( + pact, + 'consumer', + 'abc', + consumer_tags=None, + tag_with_git_branch=False, + pact_dir='some_dir', + branch=None, + build_url=None, + auto_detect_version_properties=False) + + def test_stop_fails_posix(self): + self.mock_platform.return_value = 'Linux' + self.mock_Popen.return_value.returncode = 1 + pact = Pact(Consumer('consumer'), Provider('provider')) + pact._process = Mock(spec=Popen, pid=999, returncode=1) + with self.assertRaises(RuntimeError): + pact.stop_service() + + pact._process.terminate.assert_called_once_with() + pact._process.communicate.assert_called_once_with() + self.mock_publish.assert_not_called() + + def test_stop_fails_windows(self): + self.mock_platform.return_value = 'Windows' + self.mock_Popen.return_value.returncode = 15 + self.mock_Pid_exists.return_value = True + + pact = Pact(Consumer('consumer'), Provider('provider')) + pact._process = Mock(spec=Popen, pid=999, returncode=15) + with self.assertRaises(RuntimeError): + pact.stop_service() + + self.assertFalse(pact._process.terminate.called) + self.assertFalse(pact._process.communicate.called) + self.mock_Process.assert_called_once_with(999) + self.mock_Process.return_value.children.assert_called_once_with( + recursive=True) + self.mock_Process.return_value.wait.assert_called_once_with() + self.mock_Pid_exists.assert_called_once_with(999) + self.mock_publish.assert_not_called() + + +class PactWaitForServerStartTestCase(TestCase): + def setUp(self): + super(PactWaitForServerStartTestCase, self).setUp() + self.addCleanup(patch.stopall) + self.mock_HTTPAdapter = patch.object( + pact, 'HTTPAdapter', autospec=True).start() + self.mock_Retry = patch.object(pact, 'Retry', autospec=True).start() + self.mock_Session = patch.object( + pact.requests, 'Session', autospec=True).start() + + def test_wait_for_server_start_success(self): + self.mock_Session.return_value.get.return_value.status_code = 200 + pact = Pact(Consumer('consumer'), Provider('provider')) + pact._process = Mock(spec=Popen) + pact._wait_for_server_start() + + session = self.mock_Session.return_value + session.mount.assert_called_once_with( + 'http://', self.mock_HTTPAdapter.return_value) + session.get.assert_called_once_with( + 'http://localhost:1234', + headers={'X-Pact-Mock-Service': 'true'}, + verify=False) + self.mock_HTTPAdapter.assert_called_once_with( + max_retries=self.mock_Retry.return_value) + self.mock_Retry.assert_called_once_with(total=9, backoff_factor=0.1) + self.assertFalse(pact._process.communicate.called) + self.assertFalse(pact._process.terminate.called) + + def test_wait_for_server_start_failure(self): + self.mock_Session.return_value.get.return_value.status_code = 500 + pact = Pact(Consumer('consumer'), Provider('provider')) + pact._process = Mock(spec=Popen) + with self.assertRaises(RuntimeError): + pact._wait_for_server_start() + + session = self.mock_Session.return_value + session.mount.assert_called_once_with( + 'http://', self.mock_HTTPAdapter.return_value) + session.get.assert_called_once_with( + 'http://localhost:1234', + headers={'X-Pact-Mock-Service': 'true'}, + verify=False) + self.mock_HTTPAdapter.assert_called_once_with( + max_retries=self.mock_Retry.return_value) + self.mock_Retry.assert_called_once_with(total=9, backoff_factor=0.1) + pact._process.communicate.assert_called_once_with() + pact._process.terminate.assert_called_once_with() + + +class PactVerifyTestCase(PactTestCase): + def setUp(self): + super(PactVerifyTestCase, self).setUp() + self.addCleanup(patch.stopall) + self.mock_requests = patch('requests.api.request').start() + self.target = Pact(self.consumer, self.provider) + (self.target + .given('I am creating a new pact using the Pact class') + .upon_receiving('a specific request to the server') + .with_request('GET', '/path') + .will_respond_with(200, body='success')) + self.get_verification_call = call( + 'get', 'http://localhost:1234/interactions/verification', + headers={'X-Pact-Mock-Service': 'true'}, + verify=False, + params=None) + + self.post_publish_pacts_call = call( + 'post', 'http://localhost:1234/pact', + data=None, + headers={'X-Pact-Mock-Service': 'true'}, + verify=False, + json=None) + + def test_success(self): + self.mock_requests.side_effect = iter([Mock(status_code=200)] * 2) + self.target.verify() + + self.assertEqual(self.mock_requests.call_count, 2) + self.mock_requests.assert_has_calls([ + self.get_verification_call, self.post_publish_pacts_call]) + + def test_error_verifying_interactions(self): + self.mock_requests.side_effect = iter([ + Mock(status_code=500, text='verification error')]) + + with self.assertRaises(AssertionError) as e: + self.target.verify() + + self.assertEqual(str(e.exception), 'verification error') + self.assertEqual(self.mock_requests.call_count, 1) + self.mock_requests.assert_has_calls([ + self.get_verification_call]) + + def test_error_writing_pacts_to_file(self): + self.mock_requests.side_effect = iter([ + Mock(status_code=200), + Mock(status_code=500, text='error writing pact to file')]) + + with self.assertRaises(AssertionError) as e: + self.target.verify() + + self.assertEqual(str(e.exception), 'error writing pact to file') + self.assertEqual(self.mock_requests.call_count, 2) + self.mock_requests.assert_has_calls([ + self.get_verification_call, self.post_publish_pacts_call]) + + +class PactContextManagerTestCase(PactTestCase): + def setUp(self): + super(PactContextManagerTestCase, self).setUp() + self.addCleanup(patch.stopall) + self.mock_setup = patch.object( + pact.Pact, 'setup', autospec=True).start() + + self.mock_verify = patch.object( + pact.Pact, 'verify', autospec=True).start() + + def test_successful(self): + pact = Pact(self.consumer, self.provider) + with pact: + pass + + self.mock_setup.assert_called_once_with(pact) + self.mock_verify.assert_called_once_with(pact) + + def test_context_raises_error(self): + pact = Pact(self.consumer, self.provider) + with self.assertRaises(RuntimeError): + with pact: + raise RuntimeError + + self.mock_setup.assert_called_once_with(pact) + self.assertFalse(self.mock_verify.called) + + def test_does_not_leave_interactions_after_exception(self): + pact = Pact(self.consumer, self.provider) + (pact + .given('I am creating a new pact using the Pact class') + .upon_receiving('a specific request to the server') + .with_request('GET', '/path') + .will_respond_with(200, body='success')) + with self.assertRaises(RuntimeError): + with pact: + raise RuntimeError + + assert pact._interactions == [] + + + + + +class PactContextManagerSetupTestCase(PactTestCase): + def test_definition_without_description(self): + # Description (populated from "given") is listed in the MANDATORY_FIELDS. + # Make sure if it isn't there that an exception is raised + pact = Pact(self.consumer, self.provider) + (pact.given("A request without a description") + .with_request('GET', '/path') + .will_respond_with(200, body='success')) + + self.assertEqual(len(pact._interactions), 1) + + self.assertTrue('description' not in pact._interactions[0]) + + # By using "with", __enter__ will call the setup method that will verify if this is present + with self.assertRaises(PactException): + with pact: + pact.verify() + + +class FromTermsTestCase(TestCase): + def test_json(self): + with self.assertRaises(NotImplementedError): + FromTerms().json() + + +class RequestTestCase(TestCase): + def test_sparse(self): + target = Request('GET', '/path') + result = target.json() + self.assertEqual(result, { + 'method': 'GET', + 'path': '/path'}) + + def test_all_options(self): + target = Request( + 'POST', '/path', + body='the content', + headers={'Accept': 'application/json'}, + query='term=test') + + result = target.json() + self.assertEqual(result, { + 'method': 'POST', + 'path': '/path', + 'body': 'the content', + 'headers': {'Accept': 'application/json'}, + 'query': 'term=test'}) + + def test_falsey_body(self): + target = Request('GET', '/path', body=[]) + result = target.json() + self.assertEqual(result, { + 'method': 'GET', + 'path': '/path', + 'body': []}) + + def test_matcher_in_path_gets_converted(self): + target = Request('GET', Term('\/.+', '/test-path')) # noqa: W605 + result = target.json() + self.assertTrue(isinstance(result['path'], dict)) + + +class ResponseTestCase(TestCase): + def test_sparse(self): + target = Response(200) + result = target.json() + self.assertEqual(result, {'status': 200}) + + def test_all_options(self): + target = Response( + 202, headers={'Content-Type': 'application/json'}, body='the body') + + result = target.json() + self.assertEqual(result, { + 'status': 202, + 'body': 'the body', + 'headers': {'Content-Type': 'application/json'}}) + + def test_falsey_body(self): + target = Response(200, body=[]) + result = target.json() + self.assertEqual(result, {'status': 200, 'body': []}) diff --git a/tests/v2/test_verifier.py b/tests/v2/test_verifier.py new file mode 100644 index 000000000..eab8fd763 --- /dev/null +++ b/tests/v2/test_verifier.py @@ -0,0 +1,267 @@ +from collections import OrderedDict + +from unittest import TestCase +import unittest +from mock import patch + +from pact.v2.verifier import Verifier +from pact.v2.verify_wrapper import VerifyWrapper + + +def assertVerifyCalled(mock_wrapper, *pacts, **options): + tc = unittest.TestCase() + tc.assertEqual(mock_wrapper.call_count, 1) + + mock_wrapper.assert_called_once_with(*pacts, **options) + + +class VerifierPactsTestCase(TestCase): + + def setUp(self): + super(VerifierPactsTestCase, self).setUp() + self.addCleanup(patch.stopall) + self.verifier = Verifier(provider='test_provider', + provider_base_url="http://localhost:8888") + + self.mock_wrapper = patch.object( + VerifyWrapper, 'call_verify').start() + + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch('pact.v2.verifier.path_exists', return_value=True) + def test_verifier_with_provider_and_files(self, mock_path_exists, mock_wrapper): + mock_wrapper.return_value = (True, 'some logs') + + output, _ = self.verifier.verify_pacts('path/to/pact1', + 'path/to/pact2', + headers=['header1', 'header2']) + + assertVerifyCalled(mock_wrapper, + 'path/to/pact1', + 'path/to/pact2', + provider='test_provider', + custom_provider_headers=['header1', 'header2'], + provider_base_url='http://localhost:8888', + log_level='INFO', + verbose=False, + enable_pending=False, + include_wip_pacts_since=None) + + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch('pact.v2.verifier.path_exists', return_value=True) + def test_verifier_with_provider_and_files_passes_consumer_selectors(self, mock_path_exists, mock_wrapper): + mock_wrapper.return_value = (True, 'some logs') + + output, _ = self.verifier.verify_pacts( + 'path/to/pact1', + 'path/to/pact2', + headers=['header1', 'header2'], + consumer_version_selectors=[ + # Using OrderedDict for the sake of testing + OrderedDict([("tag", "main"), ("latest", True)]), + OrderedDict([("tag", "test"), ("latest", False)]), + ] + ) + + assertVerifyCalled(mock_wrapper, + 'path/to/pact1', + 'path/to/pact2', + provider='test_provider', + custom_provider_headers=['header1', 'header2'], + provider_base_url='http://localhost:8888', + log_level='INFO', + verbose=False, + enable_pending=False, + include_wip_pacts_since=None, + consumer_selectors=['{"tag": "main", "latest": true}', + '{"tag": "test", "latest": false}']) + + def test_validate_on_publish_results(self): + self.assertRaises(Exception, self.verifier.verify_pacts, 'path/to/pact1', publish=True) + + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch('pact.v2.verifier.path_exists', return_value=True) + def test_publish_on_success(self, mock_path_exists, mock_wrapper): + mock_wrapper.return_value = (True, 'some logs') + + output, _ = self.verifier.verify_pacts('path/to/pact1', publish_version='1.0.0') + + assertVerifyCalled(mock_wrapper, + 'path/to/pact1', + provider='test_provider', + provider_base_url='http://localhost:8888', + log_level='INFO', + verbose=False, + provider_app_version='1.0.0', + enable_pending=False, + include_wip_pacts_since=None) + + @patch('pact.v2.verifier.path_exists', return_value=False) + def test_raises_error_on_missing_pact_files(self, mock_path_exists): + self.assertRaises(Exception, + self.verifier.verify_pacts, + 'path/to/pact1', 'path/to/pact2') + + mock_path_exists.assert_called_with('path/to/pact2') + + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify", return_value=(0, None)) + @patch('pact.v2.verifier.expand_directories', return_value=['./pacts/pact1', './pacts/pact2']) + @patch('pact.v2.verifier.path_exists', return_value=True) + def test_expand_directories_called_for_pacts(self, mock_path_exists, mock_expand_dir, mock_wrapper): + output, _ = self.verifier.verify_pacts('path/to/pact1', + 'path/to/pact2') + + mock_expand_dir.assert_called_once() + + @patch('pact.v2.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + def test_passes_enable_pending_flag_value(self, mock_wrapper): + for value in (True, False): + with self.subTest(value=value): + with patch('pact.v2.verifier.path_exists'): + self.verifier.verify_pacts('any.json', enable_pending=value) + self.assertTrue( + ('enable_pending', value) in mock_wrapper.call_args.kwargs.items(), + mock_wrapper.call_args.kwargs, + ) + + @patch('pact.v2.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + @patch('pact.v2.verifier.path_exists', return_value=True) + def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): + self.verifier.verify_pacts('any.json', include_wip_pacts_since='2018-01-01') + self.assertTrue( + ('include_wip_pacts_since', '2018-01-01') in mock_wrapper.call_args.kwargs.items(), + mock_wrapper.call_args.kwargs, + ) + + +class VerifierBrokerTestCase(TestCase): + + def setUp(self): + super(VerifierBrokerTestCase, self).setUp() + self.addCleanup(patch.stopall) + self.verifier = Verifier(provider='test_provider', + provider_base_url="http://localhost:8888") + + self.mock_wrapper = patch.object( + VerifyWrapper, 'call_verify').start() + self.broker_username = 'broker_username' + self.broker_password = 'broker_password' + self.broker_url = 'http://broker' + + self.default_opts = { + 'broker_username': self.broker_username, + 'broker_password': self.broker_password, + 'broker_url': self.broker_url, + 'broker_token': 'token' + } + + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + def test_verifier_with_broker(self, mock_wrapper): + + mock_wrapper.return_value = (True, 'some value') + + output, _ = self.verifier.verify_with_broker(**self.default_opts) + + self.assertTrue(output) + assertVerifyCalled(mock_wrapper, + provider='test_provider', + provider_base_url='http://localhost:8888', + broker_password=self.broker_password, + broker_username=self.broker_username, + broker_token='token', + broker_url=self.broker_url, + log_level='INFO', + verbose=False, + enable_pending=False, + include_wip_pacts_since=None) + + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + def test_verifier_and_publish_with_broker(self, mock_wrapper): + + mock_wrapper.return_value = (True, 'some value') + + self.default_opts['publish_version'] = '1.0.0' + output, _ = self.verifier.verify_with_broker(**self.default_opts) + + self.assertTrue(output) + assertVerifyCalled(mock_wrapper, + provider='test_provider', + provider_base_url='http://localhost:8888', + broker_password=self.broker_password, + broker_username=self.broker_username, + broker_token='token', + broker_url=self.broker_url, + log_level='INFO', + verbose=False, + enable_pending=False, + include_wip_pacts_since=None, + provider_app_version='1.0.0', + ) + + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + def test_verifier_with_broker_passes_consumer_selectors(self, mock_wrapper): + + mock_wrapper.return_value = (True, 'some value') + + output, _ = self.verifier.verify_with_broker( + consumer_version_selectors=[ + # Using OrderedDict for the sake of testing + OrderedDict([("tag", "main"), ("latest", True)]), + OrderedDict([("tag", "test"), ("latest", False)]), + ], + **self.default_opts + ) + + self.assertTrue(output) + assertVerifyCalled(mock_wrapper, + provider='test_provider', + provider_base_url='http://localhost:8888', + broker_password=self.broker_password, + broker_username=self.broker_username, + broker_token='token', + broker_url=self.broker_url, + log_level='INFO', + verbose=False, + enable_pending=False, + include_wip_pacts_since=None, + consumer_selectors=['{"tag": "main", "latest": true}', + '{"tag": "test", "latest": false}']) + + @patch("pact.v2.verify_wrapper.VerifyWrapper.call_verify") + @patch('pact.v2.verifier.path_exists', return_value=True) + def test_publish_on_success(self, mock_path_exists, mock_wrapper): + mock_wrapper.return_value = (True, 'some logs') + + self.verifier.verify_with_broker(publish_version='1.0.0', **self.default_opts) + + assertVerifyCalled(mock_wrapper, + provider='test_provider', + provider_base_url='http://localhost:8888', + broker_password=self.broker_password, + broker_username=self.broker_username, + broker_token='token', + broker_url=self.broker_url, + log_level='INFO', + verbose=False, + provider_app_version='1.0.0', + enable_pending=False, + include_wip_pacts_since=None) + + @patch('pact.v2.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + def test_passes_enable_pending_flag_value(self, mock_wrapper): + for value in (True, False): + with self.subTest(value=value): + with patch('pact.v2.verifier.path_exists'): + self.verifier.verify_with_broker(enable_pending=value) + self.assertTrue( + ('enable_pending', value) in mock_wrapper.call_args.kwargs.items(), + mock_wrapper.call_args.kwargs, + ) + + @patch('pact.v2.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + @patch('pact.v2.verifier.path_exists', return_value=True) + def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): + self.verifier.verify_with_broker(include_wip_pacts_since='2018-01-01') + self.assertTrue( + ('include_wip_pacts_since', '2018-01-01') in mock_wrapper.call_args.kwargs.items(), + mock_wrapper.call_args.kwargs, + ) diff --git a/tests/test_verify_wrapper.py b/tests/v2/test_verify_wrapper.py similarity index 91% rename from tests/test_verify_wrapper.py rename to tests/v2/test_verify_wrapper.py index 8ec63eaf7..f70189d41 100644 --- a/tests/test_verify_wrapper.py +++ b/tests/v2/test_verify_wrapper.py @@ -3,9 +3,9 @@ from mock import patch, Mock, call -from pact.constants import VERIFIER_PATH -from pact.verify_wrapper import VerifyWrapper, PactException, path_exists, sanitize_logs, expand_directories, rerun_command -from pact import verify_wrapper +from pact_cli import VERIFIER_PATH +from pact.v2.verify_wrapper import VerifyWrapper, PactException, path_exists, sanitize_logs, expand_directories, rerun_command +from pact.v2 import verify_wrapper from subprocess import PIPE, Popen @@ -106,7 +106,7 @@ def test_pact_urls_provided(self): self.assertProcess(*self.default_call) self.assertEqual(result, 0) - @patch("pact.verify_wrapper.isfile", return_value=True) + @patch("pact.v2.verify_wrapper.isfile", return_value=True) def test_all_url_options(self, mock_isfile): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() @@ -161,10 +161,10 @@ def test_uses_broker_if_no_pacts_and_provider_required(self): self.assertProcess(*self.broker_call) self.assertEqual(result, 0) - @patch('pact.verify_wrapper.path_exists', return_value=True) - @patch('pact.verify_wrapper.sanitize_logs') - @patch('pact.verify_wrapper.expand_directories', return_value='./pacts/consumer-provider.json') - @patch('pact.verify_wrapper.rerun_command') + @patch('pact.v2.verify_wrapper.path_exists', return_value=True) + @patch('pact.v2.verify_wrapper.sanitize_logs') + @patch('pact.v2.verify_wrapper.expand_directories', return_value='./pacts/consumer-provider.json') + @patch('pact.v2.verify_wrapper.rerun_command') def test_rerun_command_called(self, mock_rerun_cmd, mock_expand_dirs, mock_sanitize_logs, mock_path_exists): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() @@ -176,10 +176,10 @@ def test_rerun_command_called(self, mock_rerun_cmd, mock_expand_dirs, mock_sanit mock_rerun_cmd.assert_called_once() - @patch('pact.verify_wrapper.path_exists', return_value=True) - @patch('pact.verify_wrapper.sanitize_logs') - @patch('pact.verify_wrapper.expand_directories', return_value='./pacts/consumer-provider.json') - @patch('pact.verify_wrapper.rerun_command') + @patch('pact.v2.verify_wrapper.path_exists', return_value=True) + @patch('pact.v2.verify_wrapper.sanitize_logs') + @patch('pact.v2.verify_wrapper.expand_directories', return_value='./pacts/consumer-provider.json') + @patch('pact.v2.verify_wrapper.rerun_command') def test_sanitize_called(self, mock_rerun_cmd, mock_expand_dirs, mock_sanitize_logs, mock_path_exists): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() @@ -191,8 +191,8 @@ def test_sanitize_called(self, mock_rerun_cmd, mock_expand_dirs, mock_sanitize_l mock_sanitize_logs.assert_called_with(self.mock_Popen.return_value, False) - @patch('pact.verify_wrapper.path_exists', return_value=True) - @patch('pact.verify_wrapper.sanitize_logs') + @patch('pact.v2.verify_wrapper.path_exists', return_value=True) + @patch('pact.v2.verify_wrapper.sanitize_logs') def test_publishing_with_version(self, mock_sanitize_logs, mock_path_exists): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() @@ -209,10 +209,10 @@ def test_publishing_with_version(self, mock_sanitize_logs, mock_path_exists): self.assertProcess(*self.default_call) self.assertEqual(result, 0) - @patch('pact.verify_wrapper.path_exists', return_value=True) - @patch('pact.verify_wrapper.sanitize_logs') - @patch('pact.verify_wrapper.expand_directories', return_value='./pacts/consumer-provider.json') - @patch('pact.verify_wrapper.rerun_command') + @patch('pact.v2.verify_wrapper.path_exists', return_value=True) + @patch('pact.v2.verify_wrapper.sanitize_logs') + @patch('pact.v2.verify_wrapper.expand_directories', return_value='./pacts/consumer-provider.json') + @patch('pact.v2.verify_wrapper.rerun_command') def test_expand_dirs_called(self, mock_rerun_cmd, mock_expand_dirs, mock_sanitize_logs, mock_path_exists): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() @@ -252,7 +252,7 @@ def setUp(self): '/pact-provider-verifier.rb:2:in `
\'' ]) - @patch('pact.verify_wrapper.sys.stdout.write') + @patch('pact.v2.verify_wrapper.sys.stdout.write') def test_verbose(self, mock_write): sanitize_logs(self.process, True) mock_write.assert_has_calls([ @@ -265,7 +265,7 @@ def test_verbose(self, mock_write): '/app/pact-provider-verifier.rb:2:in `
\'') ]) - @patch('pact.verify_wrapper.sys.stdout.write') + @patch('pact.v2.verify_wrapper.sys.stdout.write') def test_terse(self, mock_write): sanitize_logs(self.process, False) mock_write.assert_called_once_with( @@ -354,7 +354,7 @@ def test_windows(self): " & set PACT_DESCRIPTION=" " & set PACT_PROVIDER_STATE=\"") - @patch('pact.verify_wrapper.os.environ') + @patch('pact.v2.verify_wrapper.os.environ') def test_env_copied(self, mock_env): mock_env.copy.return_value = {'foo': 'bar'} self.mock_platform.return_value = 'linux' diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 2cfa5fdeb..000000000 --- a/tox.ini +++ /dev/null @@ -1,8 +0,0 @@ -[tox] -envlist=py{36,37,38,39,310,311}-{test,install} -[testenv] -deps= - test: -rrequirements_dev.txt -commands= - test: pytest --cov pact tests - install: python -c "import pact"