Skip to content

Feat/async message consumer #714

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
61b8685
chore(v3): re-export Pact and Verifier at root
JP-Ellis Jun 21, 2024
cc3034a
feat(ffi): upgrade ffi 0.4.21
JP-Ellis Jun 21, 2024
088a9bb
feat(v3): add enum type aliases
JP-Ellis Jun 21, 2024
f25f8f3
chore(ffi): disable private usage lint
JP-Ellis Jun 22, 2024
8bb0ef9
chore(ffi): implement AsynchronousMessage
JP-Ellis Jun 21, 2024
f182077
chore(ffi): implement Generator
JP-Ellis Jun 21, 2024
901cfc5
chore(ffi): implement MatchingRule
JP-Ellis Jun 21, 2024
1d504d0
chore(ffi): remove old message and message handle
JP-Ellis Jun 22, 2024
816c9c7
chore(ffi): implement MessageContents
JP-Ellis Jun 23, 2024
e385582
chore(ffi): implement MessageMetadataPair and Iterator
JP-Ellis Jun 23, 2024
64ca049
chore(ffi): implement ProviderState and related
JP-Ellis Jun 23, 2024
8a72416
chore(ffi): implement SynchronousHttp
JP-Ellis Jun 24, 2024
ac0535d
chore(ffi): implement SynchronousMessage
JP-Ellis Jun 24, 2024
b33adb4
docs(ffi): properly document exceptions
JP-Ellis Jun 24, 2024
31b1143
chore(ffi): bump links to 0.4.21
JP-Ellis Jun 24, 2024
5cb4a8a
docs: minor refinements
JP-Ellis Jun 24, 2024
1f635db
docs(example): clarify purpose of fs interface
JP-Ellis Jun 24, 2024
48ee615
feat(v3): improve exception types
JP-Ellis Jun 24, 2024
6e7da41
feat(v3): remove deprecated messages iterator
JP-Ellis Jun 24, 2024
6d3c1dc
refactor(v3): new interaction iterators
JP-Ellis Jun 24, 2024
c1d970e
feat(v3): implement message verification
JP-Ellis Jun 24, 2024
7f010c1
chore(tests): implement v3/v4 consumer message compatibility suite
JP-Ellis Jun 24, 2024
ea7c231
chore(examples): add v3 message consumer examples
JP-Ellis Jun 24, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,12 @@ jobs:
- os: ubuntu-20.04
archs: aarch64
build: musllinux
- os: macos-12
- os: macos-14
archs: arm64
build: ""
- os: windows-2019
archs: ARM64
build: ""

steps:
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
src/pact/bin
src/pact/data

# Test outputs
examples/tests/pacts

# Version is determined from the VCS
src/pact/__version__.py

Expand Down
4 changes: 3 additions & 1 deletion examples/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ services:
broker:
image: pactfoundation/pact-broker:latest-multi
depends_on:
- postgres
postgres:
condition: service_healthy
ports:
- "9292:9292"
restart: always
Expand All @@ -41,3 +42,4 @@ services:
interval: 1s
timeout: 2s
retries: 5
start_period: 30s
17 changes: 16 additions & 1 deletion examples/src/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,22 @@


class Filesystem:
"""Filesystem interface."""
"""
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."""
Expand Down
2 changes: 2 additions & 0 deletions examples/tests/test_01_provider_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from __future__ import annotations

import time
from multiprocessing import Process
from typing import Any, Dict, Generator, Union
from unittest.mock import MagicMock
Expand Down Expand Up @@ -93,6 +94,7 @@ def verifier() -> Generator[Verifier, Any, None]:
provider_base_url=str(PROVIDER_URL),
)
proc.start()
time.sleep(2)
yield verifier
proc.kill()

Expand Down
2 changes: 2 additions & 0 deletions examples/tests/test_01_provider_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from __future__ import annotations

import time
from multiprocessing import Process
from typing import Any, Dict, Generator, Union
from unittest.mock import MagicMock
Expand Down Expand Up @@ -81,6 +82,7 @@ def verifier() -> Generator[Verifier, Any, None]:
provider_base_url=str(PROVIDER_URL),
)
proc.start()
time.sleep(2)
yield verifier
proc.kill()

Expand Down
Empty file added examples/tests/v3/__init__.py
Empty file.
170 changes: 170 additions & 0 deletions examples/tests/v3/test_01_message_consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""
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,
Dict,
Generator,
)
from unittest.mock import MagicMock

import pytest

from examples.src.message import Handler
from pact.v3.pact import Pact

if TYPE_CHECKING:
from collections.abc import Callable


log = logging.getLogger(__name__)


@pytest.fixture(scope="module")
def pact() -> 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_dir = Path(Path(__file__).parent.parent / "pacts")
pact = Pact("v3_message_consumer", "v3_message_provider")
log.info("Creating Message Pact with V3 specification")
yield pact.with_specification("V3")
pact.write_file(pact_dir, 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!",
})
)
)
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",
"contents": "Hello, world!",
})
)
)
pact.verify(verifier, "Async")

handler.fs.read.assert_called_once_with("my_file.txt")
6 changes: 4 additions & 2 deletions hatch_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

# Latest version available at:
# https://github.com/pact-foundation/pact-reference/releases
PACT_LIB_VERSION = os.getenv("PACT_LIB_VERSION", "0.4.19")
PACT_LIB_VERSION = os.getenv("PACT_LIB_VERSION", "0.4.21")
PACT_LIB_URL = "https://github.com/pact-foundation/pact-reference/releases/download/libpact_ffi-v{version}/{prefix}pact_ffi-{os}-{machine}.{ext}"


Expand Down Expand Up @@ -256,7 +256,7 @@ def _pact_lib_url(self, version: str) -> str: # noqa: C901, PLR0912
if platform.startswith("macosx"):
os = "macos"
if platform.endswith("arm64"):
machine = "aarch64-apple-darwin"
machine = "aarch64"
elif platform.endswith("x86_64"):
machine = "x86_64"
else:
Expand All @@ -274,6 +274,8 @@ def _pact_lib_url(self, version: str) -> str: # noqa: C901, PLR0912

if platform.endswith("amd64"):
machine = "x86_64"
elif platform.endswith(("arm64", "aarch64")):
machine = "aarch64"
else:
raise UnsupportedPlatformError(platform)
return PACT_LIB_URL.format(
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,10 @@ addopts = [
"--cov-report=xml",
]
filterwarnings = [
"ignore::DeprecationWarning:examples",
"ignore::DeprecationWarning:pact",
"ignore::DeprecationWarning:tests",
"ignore::PendingDeprecationWarning:examples",
"ignore::PendingDeprecationWarning:pact",
"ignore::PendingDeprecationWarning:tests",
]
Expand Down
6 changes: 4 additions & 2 deletions src/pact/v3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,14 @@

import warnings

from pact.v3.pact import Pact # noqa: F401
from pact.v3.verifier import Verifier # noqa: F401
from pact.v3.pact import Pact
from pact.v3.verifier import Verifier

warnings.warn(
"The `pact.v3` module is not yet stable. Use at your own risk, and expect "
"breaking changes in future releases.",
stacklevel=2,
category=ImportWarning,
)

__all__ = ["Pact", "Verifier"]
Loading
Loading