From 378fee8eb0d8045e1432680d8ee0d35f866aef16 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:47:59 +1100 Subject: [PATCH 01/10] chore(deps): update ruff to v0.15.7 (#1506) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- pact-python-cli/pyproject.toml | 2 +- pact-python-ffi/pyproject.toml | 2 +- pyproject.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7217b34da..355642b7e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: - id: biome-check - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.6 + rev: v0.15.7 hooks: - id: ruff-check exclude: | diff --git a/pact-python-cli/pyproject.toml b/pact-python-cli/pyproject.toml index 485016b84..13ef37b75 100644 --- a/pact-python-cli/pyproject.toml +++ b/pact-python-cli/pyproject.toml @@ -54,7 +54,7 @@ requires-python = ">=3.10" [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.6", { include-group = "test" }, { include-group = "types" }] +dev = ["ruff==0.15.7", { include-group = "test" }, { include-group = "types" }] test = ["pytest-cov~=7.0", "pytest~=9.0"] types = ["mypy==1.19.1"] diff --git a/pact-python-ffi/pyproject.toml b/pact-python-ffi/pyproject.toml index bfd14df1b..9d4ab7891 100644 --- a/pact-python-ffi/pyproject.toml +++ b/pact-python-ffi/pyproject.toml @@ -41,7 +41,7 @@ dependencies = ["cffi~=2.0"] "Repository" = "https://github.com/pact-foundation/pact-python" [dependency-groups] -dev = ["ruff==0.15.6", { include-group = "test" }, { include-group = "types" }] +dev = ["ruff==0.15.7", { include-group = "test" }, { include-group = "types" }] test = ["pytest-cov~=7.0", "pytest~=9.0"] types = ["mypy==1.19.1", "typing-extensions~=4.0"] diff --git a/pyproject.toml b/pyproject.toml index 20a4adeb0..dd4232ae5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ dependencies = [ # Linting and formatting tools use a more narrow specification to ensure # developper consistency. All other dependencies are as above. dev = [ - "ruff==0.15.6", + "ruff==0.15.7", { include-group = "docs" }, { include-group = "example" }, { include-group = "test" }, From a7b995b5bdf398b051c8b9a8a04681195f2ef833 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:48:17 +1100 Subject: [PATCH 02/10] chore(deps): update dependency mkdocs-material to v9.7.6 (#1505) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dd4232ae5..d0c7355fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,7 @@ docs = [ "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.5", + "mkdocs-material[recommended,git,imaging]==9.7.6", "mkdocs-section-index==0.3.11", "mkdocs==1.6.1", "mkdocstrings[python]==1.0.3", From 1753c0ac2f764589967cbf38c3b4f7da0e531437 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:49:34 +1100 Subject: [PATCH 03/10] chore(deps): update pre-commit hook biomejs/pre-commit to v2.4.8 (#1504) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 355642b7e..3c38de0a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: - id: check-json5 - repo: https://github.com/biomejs/pre-commit - rev: v2.4.7 + rev: v2.4.8 hooks: - id: biome-check From 14f15947ca46193fa11b054efb35b2fd1fddd85d Mon Sep 17 00:00:00 2001 From: Benjamin Aduo Date: Fri, 20 Mar 2026 00:44:29 +0100 Subject: [PATCH 04/10] feat(examples): add XML contract testing example (Issue #372) - Add examples/http/xml_example/ with consumer, provider, and tests - Consumer uses xml.etree.ElementTree to parse XML responses - Provider is a FastAPI app returning application/xml responses - Tests cover GET user (200) and unknown user (404) scenarios - Add local conftest.py with pacts_path fixture - Update examples/http/README.md to list new example --- examples/http/README.md | 1 + examples/http/xml_example/__init__.py | 0 examples/http/xml_example/conftest.py | 13 ++ examples/http/xml_example/consumer.py | 110 +++++++++++++++++ examples/http/xml_example/provider.py | 103 ++++++++++++++++ examples/http/xml_example/pyproject.toml | 27 +++++ examples/http/xml_example/test_consumer.py | 93 ++++++++++++++ examples/http/xml_example/test_provider.py | 135 +++++++++++++++++++++ 8 files changed, 482 insertions(+) create mode 100644 examples/http/xml_example/__init__.py create mode 100644 examples/http/xml_example/conftest.py create mode 100644 examples/http/xml_example/consumer.py create mode 100644 examples/http/xml_example/provider.py create mode 100644 examples/http/xml_example/pyproject.toml create mode 100644 examples/http/xml_example/test_consumer.py create mode 100644 examples/http/xml_example/test_provider.py diff --git a/examples/http/README.md b/examples/http/README.md index 4f97723ca..1579c6312 100644 --- a/examples/http/README.md +++ b/examples/http/README.md @@ -6,3 +6,4 @@ This directory contains examples of HTTP-based contract testing with Pact. - [`aiohttp_and_flask/`](aiohttp_and_flask/) - Async aiohttp consumer with Flask provider - [`requests_and_fastapi/`](requests_and_fastapi/) - requests consumer with FastAPI provider +- [`xml_example/`](xml_example/) - requests consumer with FastAPI provider using XML bodies diff --git a/examples/http/xml_example/__init__.py b/examples/http/xml_example/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/http/xml_example/conftest.py b/examples/http/xml_example/conftest.py new file mode 100644 index 000000000..13e9dee94 --- /dev/null +++ b/examples/http/xml_example/conftest.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +EXAMPLE_DIR = Path(__file__).parent.resolve() + + +@pytest.fixture(scope="session") +def pacts_path() -> Path: + return EXAMPLE_DIR / "pacts" diff --git a/examples/http/xml_example/consumer.py b/examples/http/xml_example/consumer.py new file mode 100644 index 000000000..5f080609b --- /dev/null +++ b/examples/http/xml_example/consumer.py @@ -0,0 +1,110 @@ +""" +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]. + +The consumer sends requests expecting XML responses and parses them using the +standard library [`xml.etree.ElementTree`][xml.etree.ElementTree] module. + +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. +""" + +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. + """ + + id: int + name: str + + +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() + root = ET.fromstring(response.text) # noqa: S314 + return User( + id=int(root.findtext("id")), + name=root.findtext("name"), + ) diff --git a/examples/http/xml_example/provider.py b/examples/http/xml_example/provider.py new file mode 100644 index 000000000..1a051874f --- /dev/null +++ b/examples/http/xml_example/provider.py @@ -0,0 +1,103 @@ +""" +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]. + +The provider receives requests from the consumer and returns XML responses built +using the standard library [`xml.etree.ElementTree`][xml.etree.ElementTree] +module. + +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. +""" + +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. + """ + + id: int + name: str + + +class UserDb: + """ + A simple in-memory user database abstraction for demonstration purposes. + """ + + _db: ClassVar[dict[int, User]] = {} + + @classmethod + def create(cls, user: User) -> None: + """ + Add a new user to the database. + """ + cls._db[user.id] = user + + @classmethod + def delete(cls, user_id: int) -> None: + """ + Delete a user from the database by their ID. + + 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. + """ + 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..3275a020e --- /dev/null +++ b/examples/http/xml_example/pyproject.toml @@ -0,0 +1,27 @@ +#:schema https://www.schemastore.org/pyproject.json +[project] +name = "example-xml" + +description = "Example of XML contract testing with Pact Python" + +dependencies = ["requests~=2.0", "fastapi~=0.0", "typing-extensions~=4.0"] +requires-python = ">=3.10" +version = "1.0.0" + +[dependency-groups] +test = ["pact-python", "pytest~=9.0", "uvicorn~=0.29"] + +[tool.uv.sources] +pact-python = { path = "../../../" } + +[tool.ruff] +extend = "../../../pyproject.toml" + +[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" diff --git a/examples/http/xml_example/test_consumer.py b/examples/http/xml_example/test_consumer.py new file mode 100644 index 000000000..a7f758046 --- /dev/null +++ b/examples/http/xml_example/test_consumer.py @@ -0,0 +1,93 @@ +""" +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 key difference from JSON-based examples is that the response +body is specified as a plain XML string — no matchers are used, as XML matchers +do not exist in pact-python. The `Accept` header is set via a separate +`.with_header()` call after `.with_request()`. +""" + +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 + +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. + + 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. + + The response body is a plain XML string. Note that `.with_header()` is + called as a separate chain step — `with_request()` does not accept a + headers argument. + """ + response = "123Alice" + ( + 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. + """ + ( + 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..11c1e8b89 --- /dev/null +++ b/examples/http/xml_example/test_provider.py @@ -0,0 +1,135 @@ +""" +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. + +Provider state handlers set up the in-memory database before each interaction +is verified, ensuring repeatable and isolated contract verification. +""" + +from __future__ import annotations + +import contextlib +import logging +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() + return f"http://{hostname}:{port}" + + +def test_provider(app_server: str, pacts_path: Path) -> None: + """ + Test the provider against the consumer contract. + + Runs the Pact verifier against the FastAPI provider using the contract + generated by the consumer tests. State handlers ensure the database is + in the correct state for each interaction. + """ + 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. + + Args: + action: + Either "setup" or "teardown". + parameters: + Must contain "id" and optionally "name". + """ + 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. + + Args: + action: + Either "setup" or "teardown". + parameters: + Must contain "id". + """ + 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": + return + + msg = f"Unknown action: {action}" + raise ValueError(msg) From 1cea9a0da6e3b09af42779193ce0922a1484f072 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:37:47 +1100 Subject: [PATCH 05/10] chore(deps): update pre-commit hook davidanson/markdownlint-cli2 to v0.22.0 (#1509) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3c38de0a7..860a6506a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,7 +61,7 @@ repos: - id: committed - repo: https://github.com/DavidAnson/markdownlint-cli2 - rev: v0.21.0 + rev: v0.22.0 hooks: - id: markdownlint-cli2 From fe24dc0c8232bd59646134755b883825509d23a1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:38:01 +1100 Subject: [PATCH 06/10] chore(deps): update taiki-e/install-action action to v2.69.6 (#1510) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-cli.yml | 2 +- .github/workflows/build-ffi.yml | 2 +- .github/workflows/build.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 41cdcdf24..70e82ac4e 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -140,7 +140,7 @@ jobs: fetch-depth: 0 - name: Install git cliff and typos - uses: taiki-e/install-action@94a7388bec5d4c8dd93e3ebf09e0ff448f3f6f4d # v2.68.35 + uses: taiki-e/install-action@06203676c62f0d3c765be3f2fcfbebbcb02d09f5 # v2.69.6 with: tool: git-cliff,typos diff --git a/.github/workflows/build-ffi.yml b/.github/workflows/build-ffi.yml index bd0991ecb..95771164d 100644 --- a/.github/workflows/build-ffi.yml +++ b/.github/workflows/build-ffi.yml @@ -141,7 +141,7 @@ jobs: fetch-depth: 0 - name: Install git cliff and typos - uses: taiki-e/install-action@94a7388bec5d4c8dd93e3ebf09e0ff448f3f6f4d # v2.68.35 + uses: taiki-e/install-action@06203676c62f0d3c765be3f2fcfbebbcb02d09f5 # v2.69.6 with: tool: git-cliff,typos diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d791ab06..e16e247f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -109,7 +109,7 @@ jobs: fetch-depth: 0 - name: Install git cliff and typos - uses: taiki-e/install-action@94a7388bec5d4c8dd93e3ebf09e0ff448f3f6f4d # v2.68.35 + uses: taiki-e/install-action@06203676c62f0d3c765be3f2fcfbebbcb02d09f5 # v2.69.6 with: tool: git-cliff,typos From d0a4603d1a2004949ecbe75c70004e88ae2ad616 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 04:42:12 +0000 Subject: [PATCH 07/10] chore(deps): update python:3.14-slim docker digest to fb83750 (#1508) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .devcontainer/Containerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Containerfile b/.devcontainer/Containerfile index db63bf3d8..9c34fe7ed 100644 --- a/.devcontainer/Containerfile +++ b/.devcontainer/Containerfile @@ -1,4 +1,4 @@ -FROM python:3.14-slim@sha256:584e89d31009a79ae4d9e3ab2fba078524a6c0921cb2711d05e8bb5f628fc9b9 +FROM python:3.14-slim@sha256:fb83750094b46fd6b8adaa80f66e2302ecbe45d513f6cece637a841e1025b4ca ARG USERNAME=vscode ARG USER_UID=1000 From 492210c64056a1dd28ddf5332208bc15809c410b Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 24 Mar 2026 14:24:40 +1100 Subject: [PATCH 08/10] chore: suppress error from creating logger If the logger is instantiated multiple within a session, the ffi returns an error as the subsequent call is an error. Within the context of the examples, we silence the error as they all use the same logging configuration. Signed-off-by: JP-Ellis --- examples/http/aiohttp_and_flask/conftest.py | 5 ++++- examples/http/requests_and_fastapi/conftest.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/http/aiohttp_and_flask/conftest.py b/examples/http/aiohttp_and_flask/conftest.py index 3ced5de4d..d8c4a164c 100644 --- a/examples/http/aiohttp_and_flask/conftest.py +++ b/examples/http/aiohttp_and_flask/conftest.py @@ -12,6 +12,7 @@ from __future__ import annotations +import contextlib from pathlib import Path import pytest @@ -32,4 +33,6 @@ def _setup_pact_logging() -> None: """ Set up logging for the pact package. """ - pact_ffi.log_to_stderr("INFO") + # 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/conftest.py b/examples/http/requests_and_fastapi/conftest.py index 3ced5de4d..d8c4a164c 100644 --- a/examples/http/requests_and_fastapi/conftest.py +++ b/examples/http/requests_and_fastapi/conftest.py @@ -12,6 +12,7 @@ from __future__ import annotations +import contextlib from pathlib import Path import pytest @@ -32,4 +33,6 @@ def _setup_pact_logging() -> None: """ Set up logging for the pact package. """ - pact_ffi.log_to_stderr("INFO") + # If the logger is already configured, this will raise a RuntimeError. + with contextlib.suppress(RuntimeError): + pact_ffi.log_to_stderr("INFO") From 902301011c58764c8f2507c3019e2f35dc38da67 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 24 Mar 2026 18:10:55 +1100 Subject: [PATCH 09/10] chore: appease the linter Signed-off-by: JP-Ellis --- examples/http/xml_example/__init__.py | 1 + examples/http/xml_example/conftest.py | 6 +++++- examples/http/xml_example/consumer.py | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/http/xml_example/__init__.py b/examples/http/xml_example/__init__.py index e69de29bb..6e031999e 100644 --- a/examples/http/xml_example/__init__.py +++ 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 index 13e9dee94..92313287f 100644 --- a/examples/http/xml_example/conftest.py +++ b/examples/http/xml_example/conftest.py @@ -1,13 +1,17 @@ +""" +Pytest fixture (placeholder, will be completed). +""" + from __future__ import annotations from pathlib import Path import pytest - EXAMPLE_DIR = Path(__file__).parent.resolve() @pytest.fixture(scope="session") def pacts_path() -> Path: + """Setup the path for generated pact files.""" return EXAMPLE_DIR / "pacts" diff --git a/examples/http/xml_example/consumer.py b/examples/http/xml_example/consumer.py index 5f080609b..798c6c003 100644 --- a/examples/http/xml_example/consumer.py +++ b/examples/http/xml_example/consumer.py @@ -105,6 +105,6 @@ def get_user(self, user_id: int) -> User: response.raise_for_status() root = ET.fromstring(response.text) # noqa: S314 return User( - id=int(root.findtext("id")), - name=root.findtext("name"), + id=int(root.findtext("id") or 0), + name=root.findtext("name") or "", ) From 3db43a59ed1f00b3d15f1c0e324893b65a364470 Mon Sep 17 00:00:00 2001 From: Benjamin Aduo Date: Thu, 26 Mar 2026 13:43:30 +0100 Subject: [PATCH 10/10] fix(examples): wait for server port before returning base URL Resolves a race condition where the provider verification could start before uvicorn had finished binding the port, causing flaky CI failures. --- examples/http/xml_example/test_provider.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/http/xml_example/test_provider.py b/examples/http/xml_example/test_provider.py index 11c1e8b89..2c72233fd 100644 --- a/examples/http/xml_example/test_provider.py +++ b/examples/http/xml_example/test_provider.py @@ -14,6 +14,8 @@ import contextlib import logging +import socket +import time from threading import Thread from typing import TYPE_CHECKING, Any, Literal @@ -46,6 +48,11 @@ def app_server() -> str: kwargs={"host": hostname, "port": port}, daemon=True, ).start() + for _ in range(50): + with contextlib.suppress(ConnectionRefusedError, OSError): + with socket.create_connection((hostname, port), timeout=0.1): + break + time.sleep(0.1) return f"http://{hostname}:{port}"