From 31bd3792311d8c16d0b221578078ae584671b8b8 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Wed, 15 May 2024 18:00:45 -0600 Subject: [PATCH 01/61] Adding v3 and v4 message consumer tests --- examples/tests/test_01_provider_fastapi.py | 3 + examples/tests/test_01_provider_flask.py | 3 + pyproject.toml | 3 +- src/pact/v3/ffi.py | 553 ++++++++++++++++-- .../interaction/_async_message_interaction.py | 45 +- src/pact/v3/pact.py | 89 ++- .../test_v3_message_consumer.py | 543 +++++++++++++++++ .../test_v4_message_consumer.py | 171 ++++++ 8 files changed, 1356 insertions(+), 54 deletions(-) create mode 100644 tests/v3/compatibility_suite/test_v3_message_consumer.py create mode 100644 tests/v3/compatibility_suite/test_v4_message_consumer.py diff --git a/examples/tests/test_01_provider_fastapi.py b/examples/tests/test_01_provider_fastapi.py index 7ccd3128a..bea18f23d 100644 --- a/examples/tests/test_01_provider_fastapi.py +++ b/examples/tests/test_01_provider_fastapi.py @@ -36,6 +36,8 @@ from examples.src.fastapi import app from pact import Verifier +import time + PROVIDER_URL = URL("http://localhost:8080") @@ -145,6 +147,7 @@ def test_against_broker(broker: URL, verifier: Verifier) -> None: For an example of the consumer's contract, see the consumer's tests. """ + time.sleep(3) code, _ = verifier.verify_with_broker( broker_url=str(broker), # Despite the auth being set in the broker URL, we still need to pass diff --git a/examples/tests/test_01_provider_flask.py b/examples/tests/test_01_provider_flask.py index ba5c39d43..2a5430180 100644 --- a/examples/tests/test_01_provider_flask.py +++ b/examples/tests/test_01_provider_flask.py @@ -35,6 +35,8 @@ from examples.src.flask import app from pact import Verifier +import time + PROVIDER_URL = URL("http://localhost:8080") @@ -133,6 +135,7 @@ def test_against_broker(broker: URL, verifier: Verifier) -> None: For an example of the consumer's contract, see the consumer's tests. """ + time.sleep(3) code, _ = verifier.verify_with_broker( broker_url=str(broker), # Despite the auth being set in the broker URL, we still need to pass diff --git a/pyproject.toml b/pyproject.toml index a8ef954c2..6521f9067 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,7 @@ devel-test = [ "pytest-bdd ~=7.0", "pytest-cov ~=5.0", "testcontainers ~=3.0", + "jsonpath-ng", ] devel = ["pact-python[devel-types,devel-docs,devel-test]", "ruff==0.4.2"] @@ -160,7 +161,7 @@ extra-dependencies = [ lint = "ruff check --output-format=full --show-fixes {args}" typecheck = "mypy {args:.}" format = "ruff format {args}" -test = "pytest tests/ {args}" +test = "pytest tests/v3/compatibility_suite/test_v?_message_consumer.py {args}" example = "pytest examples/ {args}" all = ["format", "lint", "typecheck", "test", "example"] docs = "mkdocs serve {args}" diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index e23d94b86..79747cef8 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -178,25 +178,216 @@ class MatchingRuleKeyValuePair: ... class MatchingRuleResult: ... -class Message: ... +class Message: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Message + + Args: + ptr: + CFFI data structure. + """ + if ffi.typeof(ptr).cname != "struct Message *": + msg = ( + "ptr must be a struct Message, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "Message" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"Message({self._ptr!r})" + + @property + def description(self) -> str: + return ffi.string(lib.pactffi_message_get_description(self._ptr)).decode('utf-8') + + @property + def contents(self) -> str: + _contents = lib.pactffi_message_get_contents(self._ptr) + if _contents != ffi.NULL: + return ffi.string(lib.pactffi_message_get_contents(self._ptr)).decode('utf-8') + return None + + @property + def metadata(self) -> list[MessageMetadataPair]: + return [{m.key: m.value} for m in message_get_metadata_iter(self)] class MessageContents: ... -class MessageHandle: ... +class MessageHandle: + """ + Handle to a Message. + + [Rust + `MessageHandle`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/mock_server/handles/struct.MessageHandle.html) + """ + + def __init__(self, ref: int) -> None: + """ + Initialise a new Message Handle. + + Args: + ref: + Reference to the Message Handle. + """ + self._ref: int = ref + + def __str__(self) -> str: + """ + String representation of the Message Handle. + """ + return f"MessageHandle({self._ref})" + + def __repr__(self) -> str: + """ + String representation of the Message Handle. + """ + return f"MessageHandle({self._ref!r})" + + +class MessageMetadataIterator: + """ + Iterator over a Message's metadata + """ + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Message Metadata Iterator. + + Args: + ptr: + CFFI data structure. + """ + 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 Message Metadata Iterator. + """ + message_metadata_iter_delete(self) + + def __iter__(self) -> MessageMetadataIterator: + return self + + def __next__(self) -> MessageMetadataPair: + """ + Get the next interaction from the iterator. + """ + msg = message_metadata_iter_next(self) + if msg == ffi.NULL: + raise StopIteration + return MessageMetadataPair(msg) + + + +class MessageMetadataPair: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new MessageMetadataPair. + + Args: + ptr: + CFFI data structure. + """ + if ffi.typeof(ptr).cname != "struct MessageMetadataPair *": + msg = ( + "ptr must be a struct MessageMetadataPair, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + def __str__(self) -> str: + """ + Nice string representation. + """ + return "MessageMetadataPair" -class MessageMetadataIterator: ... + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"MessageMetadataPair({self._ptr!r})" + @property + def key(self) -> str: + return ffi.string(self._ptr.key).decode('utf-8') -class MessageMetadataPair: ... + @property + def value(self) -> str: + return ffi.string(self._ptr.value).decode('utf-8') class MessagePact: ... -class MessagePactHandle: ... +class MessagePactHandle: + """ + Handle to a Pact. + + [Rust + `PactHandle`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/mock_server/handles/struct.PactHandle.html) + """ + + def __init__(self, ref: int) -> None: + """ + Initialise a new Message Pact Handle. + + Args: + ref: + Rust library reference to the Pact Handle. + """ + self._ref: int = ref + + def __del__(self) -> None: + """ + Destructor for the Message Pact Handle. + """ + cleanup_plugins(self) + free_message_pact_handle(self) + + def __str__(self) -> str: + """ + String representation of the Message Pact Handle. + """ + return f"MessagePactHandle({self._ref})" + + def __repr__(self) -> str: + """ + String representation of the Message Pact Handle. + """ + return f"MessagePactHandle({self._ref!r})" + class MessagePactMessageIterator: ... @@ -352,6 +543,9 @@ def __del__(self) -> None: """ pact_interaction_iter_delete(self) + def __iter__(self) -> Self: + return self + def __next__(self) -> PactInteraction: """ Get the next interaction from the iterator. @@ -517,16 +711,185 @@ def __next__(self) -> SynchronousMessage: class Provider: ... -class ProviderState: ... +class ProviderState: + + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new ProviderState + + Args: + ptr: + CFFI data structure. + """ + if ffi.typeof(ptr).cname != "struct ProviderState *": + msg = ( + "ptr must be a struct ProviderState, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "ProviderState" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"ProviderState({self._ptr!r})" + + @property + def name(self) -> str: + return ffi.string(lib.pactffi_provider_state_get_name(self._ptr)).decode('utf-8') + + @property + def parameters(self) -> list[ProviderStateParamPair]: + return [{p.key: p.value} for p in provider_state_get_param_iter(self)] + + +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. + """ + 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) -> ProviderStateParamPair: + """ + 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. + """ + 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) -> ProviderStateParam: + """ + 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. -class ProviderStateIterator: ... + Args: + ptr: + CFFI data structure. + """ + 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" -class ProviderStateParamIterator: ... + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"ProviderStateParamPair({self._ptr!r})" + @property + def key(self) -> str: + return ffi.string(self._ptr.key).decode('utf-8') -class ProviderStateParamPair: ... + @property + def value(self) -> str: + return ffi.string(self._ptr.value).decode('utf-8') class SynchronousHttp: ... @@ -3033,7 +3396,6 @@ def pact_message_iter_next(iter: PactMessageIterator) -> Message: ptr = lib.pactffi_pact_message_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration - raise NotImplementedError return Message(ptr) @@ -3207,7 +3569,11 @@ def message_new_from_json( If the JSON string is invalid or not UTF-8 encoded, returns a NULL. """ - raise NotImplementedError + return lib.pactffi_message_new_from_json( + ffi.new('unsigned int *', index)[0], + ffi.new('char[]', json_str.encode("utf-8")), + spec_version, + ) def message_new_from_body(body: str, content_type: str) -> Message: @@ -3436,7 +3802,7 @@ def message_get_provider_state_iter(message: Message) -> ProviderStateIterator: Returns NULL if an error occurs. """ - raise NotImplementedError + return ProviderStateIterator(lib.pactffi_message_get_provider_state_iter(message._ptr)) def provider_state_iter_next(iter: ProviderStateIterator) -> ProviderState: @@ -3457,7 +3823,10 @@ def provider_state_iter_next(iter: ProviderStateIterator) -> ProviderState: Returns NULL if an error occurs. """ - raise NotImplementedError + 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: @@ -3493,7 +3862,7 @@ def message_find_metadata(message: Message, key: str) -> str: This function may fail if the provided `key` string contains invalid UTF-8, or if the Rust string contains embedded null ('\0') bytes. """ - raise NotImplementedError + return ffi.string(lib.pactffi_message_find_metadata(message._ptr, key.encode('utf-8'))) def message_insert_metadata(message: Message, key: str, value: str) -> int: @@ -3536,7 +3905,10 @@ def message_metadata_iter_next(iter: MessageMetadataIterator) -> MessageMetadata If no further data is present, returns NULL. """ - raise NotImplementedError + message_metadata = lib.pactffi_message_metadata_iter_next(iter._ptr) + if message_metadata == ffi.NULL: + raise StopIteration + return message_metadata def message_get_metadata_iter(message: Message) -> MessageMetadataIterator: @@ -3561,7 +3933,7 @@ def message_get_metadata_iter(message: Message) -> MessageMetadataIterator: This function may fail if any of the Rust strings contain embedded null ('\0') bytes. """ - raise NotImplementedError + return MessageMetadataIterator(lib.pactffi_message_get_metadata_iter(message._ptr)) def message_metadata_iter_delete(iter: MessageMetadataIterator) -> None: @@ -3571,7 +3943,7 @@ def message_metadata_iter_delete(iter: MessageMetadataIterator) -> None: [Rust `pactffi_message_metadata_iter_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_metadata_iter_delete) """ - raise NotImplementedError + lib.pactffi_message_metadata_iter_delete(iter._ptr) def message_metadata_pair_delete(pair: MessageMetadataPair) -> None: @@ -3922,7 +4294,7 @@ def provider_state_get_param_iter( This function may fail if any of the Rust strings contain embedded null ('\0') bytes. """ - raise NotImplementedError + return ProviderStateParamIterator(lib.pactffi_provider_state_get_param_iter(provider_state._ptr)) def provider_state_param_iter_next( @@ -3947,7 +4319,10 @@ def provider_state_param_iter_next( Returns NULL if there's no further elements or the iterator is NULL. """ - raise NotImplementedError + 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: @@ -3967,7 +4342,7 @@ def provider_state_param_iter_delete(iter: ProviderStateParamIterator) -> None: [Rust `pactffi_provider_state_param_iter_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_provider_state_param_iter_delete) """ - raise NotImplementedError + lib.pactffi_provider_state_param_iter_delete(iter._ptr) def provider_state_param_pair_delete(pair: ProviderStateParamPair) -> None: @@ -4710,16 +5085,11 @@ def write_pact_file( 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 + elif ret == 2: # noqa: PLR2004 msg = f"The Pact for the {mock_server_handle} was not found." else: msg = ( @@ -6052,7 +6422,12 @@ def new_message_pact(consumer_name: str, provider_name: str) -> MessagePactHandl Returns a new `MessagePactHandle`. The handle will need to be freed with the `pactffi_free_message_pact_handle` function to release its resources. """ - raise NotImplementedError + return MessagePactHandle( + lib.pactffi_new_message_pact( + consumer_name.encode("utf-8"), + provider_name.encode("utf-8"), + ), + ) def new_message(pact: MessagePactHandle, description: str) -> MessageHandle: @@ -6067,7 +6442,12 @@ def new_message(pact: MessagePactHandle, description: str) -> MessageHandle: Returns a new `MessageHandle`. """ - raise NotImplementedError + return MessageHandle( + lib.pactffi_new_message( + pact._ref, + description.encode("utf-8"), + ), + ) def message_expects_to_receive(message: MessageHandle, description: str) -> None: @@ -6085,15 +6465,24 @@ def message_expects_to_receive(message: MessageHandle, description: str) -> None def message_given(message: MessageHandle, description: str) -> None: """ - Adds a provider state to the Interaction. + Adds a provider state to the Message [Rust - `pactffi_message_given`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_given) + `pactffi_given`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_given) - * `description` - The provider state description. It needs to be unique for - each message + Args: + message: + Handle to the Message. + + description: + The provider state description. It needs to be unique. + + Raises: + RuntimeError: If the provider state could not be specified. """ - raise NotImplementedError + # message_given does not return anything, + # so we can't check for errors + lib.pactffi_message_given(message._ref, description.encode("utf-8")) def message_given_with_param( @@ -6103,22 +6492,57 @@ def message_given_with_param( value: str, ) -> None: """ - Adds a provider state to the Message with a parameter key and value. + Adds a parameter key and value to a provider state to the Message. + + 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_message_given_with_param`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_given_with_param) + `pactffi_given_with_param`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_given_with_param) + + Args: + message: + Handle to the Message. + + description: + The provider state description. + + name: + Parameter name. + + value: + Parameter value as JSON. + + Raises: + RuntimeError: If the interaction state could not be updated. + + # Errors + + Returns EXIT_FAILURE (1) if the interaction or Pact can't be modified (i.e. + the mock server for it has already started). + + Returns 2 and sets the error message (which can be retrieved with + `pactffi_get_error_message`) if the parameter values con't be parsed as + JSON. + + Returns 3 if any of the C strings are not valid. - * `description` - The provider state description. It needs to be unique. - * `name` - Parameter name. - * `value` - Parameter value. """ - raise NotImplementedError + # message_given_with_param does not return anything, + # so we can't check for errors + lib.pactffi_message_given_with_param( + message._ref, + description.encode("utf-8"), + name.encode("utf-8"), + value.encode("utf-8"), + ) def message_with_contents( message_handle: MessageHandle, content_type: str, - body: List[int], + body: str, size: int, ) -> None: """ @@ -6144,7 +6568,12 @@ def message_with_contents( * `size` - number of bytes in the message body to read. This is not required for text bodies (JSON, XML, etc.). """ - raise NotImplementedError + lib.pactffi_message_with_contents( + message_handle._ref, + content_type.encode("utf-8"), + body.encode("utf-8"), + size + ) def message_with_metadata(message_handle: MessageHandle, key: str, value: str) -> None: @@ -6201,7 +6630,11 @@ def message_with_metadata_v2( See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.19/rust/pact_ffi/IntegrationJson.md). """ - raise NotImplementedError + lib.pactffi_message_with_metadata_v2( + message_handle._ref, + key.encode("utf-8"), + value.encode("utf-8"), + ) def message_reify(message_handle: MessageHandle) -> OwnedString: @@ -6221,7 +6654,7 @@ def message_reify(message_handle: MessageHandle) -> OwnedString: from a Rust function that has a Tokio runtime in its call stack can result in a deadlock. """ - raise NotImplementedError + return OwnedString(lib.pactffi_message_reify(message_handle._ref)) def write_message_pact_file( @@ -6256,6 +6689,31 @@ def write_message_pact_file( | 1 | The pact file was not able to be written | | 2 | The message pact for the given handle was not found | """ + ret: int = lib.pactffi_write_message_pact_file( + pact._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) raise NotImplementedError @@ -6362,7 +6820,14 @@ def free_message_pact_handle(pact: MessagePactHandle) -> int: that it was previously deleted. """ - raise NotImplementedError + ret: int = lib.pactffi_free_message_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 verify(args: str) -> int: diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index 011853af3..9b1ca0125 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -3,6 +3,8 @@ """ from __future__ import annotations +from typing import Callable +import json import pact.v3.ffi from pact.v3.interaction._base import Interaction @@ -22,7 +24,11 @@ class AsyncMessageInteraction(Interaction): This class is not yet fully implemented and is not yet usable. """ - def __init__(self, pact_handle: pact.v3.ffi.PactHandle, description: str) -> None: + def __init__( + self, + pact_handle: pact.v3.ffi.PactHandle, + description: str + ) -> None: """ Initialise a new Asynchronous Message Interaction. @@ -55,3 +61,40 @@ def _handle(self) -> pact.v3.ffi.InteractionHandle: @property def _interaction_part(self) -> pact.v3.ffi.InteractionPart: return pact.v3.ffi.InteractionPart.REQUEST + + def with_content(self, content: dict[str, Any], content_type='application/json') -> Self: + """ + Set the content of the message. + + Args: + content: + The content of the message, as a dictionary. + + Returns: + The current instance of the interaction. + """ + pact.v3.ffi.message_with_contents( + self._handle, + content_type, + json.dumps(content), + 0 + ) + return self + + def with_metadata(self, metadata: dict[str, Any]) -> Self: + """ + Set the metadata of the message. + + Args: + metadata: + The metadata of the message, as a dictionary. + + Returns: + The current instance of the interaction. + """ + [ + pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) + for k, v in metadata.items() + ] + return self + diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index 231c01dd7..cc72c4489 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -277,15 +277,39 @@ def upon_receiving( Type of interaction. Defaults to `HTTP`. This must be one of `HTTP`, `Async`, or `Sync`. """ + newInteraction = None 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) + newInteraction = HttpInteraction(self._handle, description) + elif interaction == "Async": + newInteraction = AsyncMessageInteraction(self._handle, description) + elif interaction == "Sync": + newInteraction = SyncMessageInteraction(self._handle, description) + else: + msg = f"Invalid interaction type: {interaction}" + raise ValueError(msg) + self._interactions.add(newInteraction) + return newInteraction + + def verify(self, handler) -> list[dict[str, Any]]: + processed_messages = [] + for interaction in self._interactions: + msg_iter = pact.v3.ffi.pact_handle_get_message_iter(self._handle) + for msg in msg_iter: + processed_messages.append({ + "description": msg.description, + "contents": msg.contents, + "metadata": msg.metadata, + }) + try: + async_message = context = {} + if msg.contents is not None: + async_message = json.loads(msg.contents) + if msg.metadata is not None: + context = msg.metadata + handler(async_message, context) + except Exception as e: + raise e + return processed_messages def serve( # noqa: PLR0913 self, @@ -442,6 +466,54 @@ def write_file( overwrite=overwrite, ) + def write_message_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.v3.ffi.write_message_pact_file( + self._handle, + directory, + overwrite=overwrite, + ) + + def get_provider_states(self): + """ + Get the provider states for the interaction. + + Returns: + A list of provider states for the interaction. + """ + provider_state_data = [] + for message in pact.v3.ffi.pact_handle_get_message_iter(self._handle): + for provider_state in pact.v3.ffi.message_get_provider_state_iter(message): + provider_state_data.append({ + 'name': provider_state.name, + 'params': provider_state.parameters + }) + return provider_state_data + class MismatchesError(Exception): """ @@ -754,3 +826,4 @@ def write_file( str(directory), overwrite=overwrite, ) + diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py new file mode 100644 index 000000000..555c7918e --- /dev/null +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -0,0 +1,543 @@ +from __future__ import annotations + +import json +import logging +import re +import os +import pytest +from collections import namedtuple +from tempfile import TemporaryDirectory +from typing import Any, Generator + +import ast +from jsonpath_ng import parse +from pytest_bdd import ( + given, + parsers, + scenario, + then, + when, +) + +from pact.v3.pact import AsyncMessageInteraction, Pact +from tests.v3.compatibility_suite.util import parse_markdown_table + +logger = logging.getLogger(__name__) + +PactInteraction = namedtuple("PactInteraction", ["pact", "interaction"]) +PactResult = namedtuple('PactResult', ['received_payload', 'pact_data', 'error']) +ReceivedPayload = namedtuple('ReceivedPayload', ['message', 'context']) + +NUM_RE = re.compile(r'^-?[.0-9]+$') + +TEST_PACT_FILE_DIRECTORY = os.path.join(os.path.dirname(__file__), 'pacts') + +def read_json(file): + with open(os.path.join(os.path.dirname(__file__),'definition','fixtures',file)) as f: + return json.loads(f.read()) + +def compare_type(expected_type, t): + if expected_type == 'integer': + try: + int(t) + return True + except ValueError: + return False + else: + raise ValueError(f'Unknown type: {expected_type}') + +@pytest.fixture(autouse=True) +def handle_pact_file_directory(): + if not os.path.exists(TEST_PACT_FILE_DIRECTORY): + os.mkdir(TEST_PACT_FILE_DIRECTORY) + yield + os.rmdir(TEST_PACT_FILE_DIRECTORY) + +@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 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 specifying provider states' +) +def test_supports_specifying_provider_states() -> None: + """Supports specifying provider states.""" + + +@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.""" + + +@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', + '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.""" + + +################################################################################ +## Given +################################################################################ + + +@given( + 'a message integration is being defined for a consumer test', + target_fixture='pact_interaction' +) +def a_message_integration_is_being_defined_for_a_consumer_test() -> ( + Generator[tuple[Pact, AsyncMessageInteraction], Any, None] +): + """a message integration is being defined for a consumer test.""" + pact = Pact("message_consumer", "message_provider") + pact.with_specification("V3") + yield PactInteraction(pact, pact.upon_receiving("a request", "Async")) + + +@given('a message is defined') +def a_message_is_defined(pact_interaction: PactInteraction): + """a message is defined.""" + print('A message is being defined. This is not yet implemented') + + +@given( + parsers.re( + r'a provider state "(?P[^"]+)" for the message ' + r'is specified with the following data:\n(?P.+)', + re.DOTALL, + ), + converters={"table": parse_markdown_table}, +) +def a_provider_state_for_the_message_is_specified_with_the_following_data( + pact_interaction: PactInteraction, + state: str, + table: list[dict[str, Any]] +): + """a provider state for the message is specified with the following data:""" + for parameters in table: + state_params = { k: ast.literal_eval(v) for k, v in parameters.items() } + pact_interaction.interaction.given(state, parameters=state_params) + + +@given( + parsers.re(r'a provider state "(?P[^"]+)" for the message is specified') +) +def a_provider_state_for_the_message_is_specified( + pact_interaction: PactInteraction, + state: str, +): + """a provider state for the message is specified.""" + pact_interaction.interaction.given(state) + + +@given( + parsers.re('the message contains the following metadata:\n(?P
.+)', re.DOTALL), + converters={"table": parse_markdown_table}, +) +def the_message_contains_the_following_metadata( + pact_interaction: PactInteraction, + table: list[dict[str, Any]] +): + """the message contains the following metadata:""" + for metadata in table: + if metadata.get('value','').startswith('JSON: '): + metadata['value'] = metadata['value'].replace('JSON:', '') + pact_interaction.interaction.with_metadata({metadata['key']: metadata['value']}) + + +@given( + parsers.re('the message is configured with the following:\n(?P
.+)', re.DOTALL), + converters={"table": parse_markdown_table}, +) +def the_message_is_configured_with_the_following( + pact_interaction: PactInteraction, + table: list[dict[str, Any]], +): + """the message is configured with the following:""" + body_json = generator_json = metadata_json = {} + for entry in table: + for k, v in entry.items(): + if k == 'generators': + if v.startswith('JSON: '): + generator_json = json.loads(v.replace('JSON:', '')) + else: + generator_json = read_json(v) + elif k == 'body': + if v.startswith('file: '): + file = v.replace('file: ', '') + body_json = read_json(file) + elif k == 'metadata': + metadata_json = json.loads(v) + if generator_json: + category = list(generator_json.keys())[0] + if category == 'body': + for k, v in generator_json['body'].items(): + path = parse(k) + current_values = [match.value for match in path.find(body_json)] + matches = path.find(body_json) + for i, match in enumerate(matches): + generator_type = v['type'] + del v['type'] + replacement_value = { + 'value': current_values[i], + 'pact:matcher:type': 'notEmpty', + 'pact:generator:type': generator_type, + } + replacement_value.update(v) + matches[i].full_path.update(body_json, replacement_value) + elif category == 'metadata': + for k in generator_json['metadata'].keys(): + metadata = metadata_json[k] + if not isinstance(metadata, dict): + metadata = { 'value': metadata } + metadata_json[k] = metadata + generator_data = generator_json['metadata'][k] + metadata.update({ + 'pact:generator:type': generator_data['type'], + 'pact:matcher:type': 'notEmpty', + }) + del generator_data['type'] + metadata.update(generator_data) + else: + raise ValueError(f'Unknown generator category: {category}') + pact_interaction.interaction.with_content(body_json) + for k, v in metadata_json.items(): + if isinstance(v, dict): + v = json.dumps(v) + pact_interaction.interaction.with_metadata({k: str(v)}) + + +@given( + parsers.re('the message payload contains the "(?P[^"]+)" JSON document') +) +def the_message_payload_contains_the_basic_json_document( + pact_interaction: PactInteraction, + json_doc: str +): + """the message payload contains the "basic" JSON document.""" + pact_interaction.interaction.with_content(read_json(f'{json_doc}.json')) + + +################################################################################ +## When +################################################################################ + + +@when( + 'the message is NOT successfully processed with a "Test failed" exception', + target_fixture="pact_result" +) +def the_message_is_not_successfully_processed_with_an_exception( + pact_interaction: PactInteraction +): + """the message is NOT successfully processed with a "Test failed" exception.""" + # using a dict here because it's mutable + received_payload = {'data': None} + def fail(async_message, context): + received_payload['data'] = ReceivedPayload(async_message, context) + raise Exception('Test failed') + try: + pact_interaction.pact.verify(fail) + except Exception as e: + return PactResult(received_payload['data'], None, e) + + +@when( + 'the message is successfully processed', + target_fixture="pact_result" +) +def the_message_is_successfully_processed( + pact_interaction: PactInteraction +): + """the message is successfully processed.""" + received_payload = {'data': None} + def handler(async_message, context): + received_payload['data'] = ReceivedPayload(async_message, context) + pact_interaction.pact.verify(handler) + pact_interaction.pact.write_message_file(TEST_PACT_FILE_DIRECTORY, overwrite=True) + with open(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) as file: + yield PactResult(received_payload['data'], json.load(file), None) + os.remove(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) + + +################################################################################ +## Then +################################################################################ + + +@then('a Pact file for the message interaction will NOT have been written') +def a_pact_file_for_the_message_interaction_will_not_have_been_written(): + """a Pact file for the message interaction will NOT have been written.""" + assert not os.path.exists(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) + + +@then('a Pact file for the message interaction will have been written') +def a_pact_file_for_the_message_interaction_will_have_been_written(): + """a Pact file for the message interaction will have been written.""" + assert os.path.exists(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) + + +@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, +): + """the consumer test error will be "Test failed".""" + assert str(pact_result.error) == error + + +@then('the consumer test will have failed') +def the_consumer_test_will_have_failed( + pact_result: PactResult +): + """the consumer test will have failed.""" + assert type(pact_result.error) == Exception + assert pact_result.pact_data is None + + +@then('the consumer test will have passed') +def the_consumer_test_will_have_passed( + pact_result: PactResult +): + """the consumer test will have passed.""" + assert pact_result.error is None + assert pact_result.pact_data is not None + + +@then( + parsers.re(r'the first message in the Pact file will contain provider state "(?P[^"]+)"') +) +def the_first_message_in_the_pact_file_will_contain_provider_state( + pact_result: PactResult, + state: str, +): + """the first message in the Pact file will contain provider state.""" + assert state in [ + provider_state['name'] + for provider_state in pact_result.pact_data['messages'][0]['providerStates'] + ] + + +@then( + parsers.re(r'the first message in the pact file content type will be "(?P[^"]+)"') +) +def the_first_message_in_the_pact_file_content_type_will_be( + pact_result: PactResult, + content_type: str, +): + """the first message in the pact file content type will be "application/json".""" + assert pact_result.pact_data['messages'][0]['metadata']['contentType'] == content_type + + +@then( + parsers.re(r'the first message in the pact file will contain (?P[0-9]+) provider states?'), + converters={"state_count": int}, +) +def the_first_message_in_the_pact_file_will_contain( + pact_result: PactResult, + state_count: int, +): + """the first message in the pact file will contain 1 provider state.""" + assert len(pact_result.pact_data['messages'][0]['providerStates']) == state_count + + +@then( + parsers.re('the first message in the pact file will contain the "(?P[^"]+)" document') +) +def the_first_message_in_the_pact_file_will_contain_the_basic_json_document( + pact_result: PactResult, + json_doc: str +): + """the first message in the pact file will contain the "basic.json" document.""" + assert pact_result.pact_data['messages'][0]['contents'] == read_json(json_doc) + + +@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, +): + """the first message in the pact file will contain the message metadata "Origin" == "Some Text".""" + if value.startswith('JSON: '): + value = value.replace('JSON: ', '') + value = value.replace('\\"', '"') + value = json.loads(value) + assert pact_result.pact_data['messages'][0]['metadata'][key] == value + + +@then( + parsers.re( + r'the message contents for "(?P[^"]+)" ' + 'will have been replaced with an? "(?P[^"]+)"' + ) +) +def the_message_contents_will_have_been_replaced_with( + pact_result: PactResult, + replace_token: str, + expected_type: str, +): + """the message contents for "$.one" will have been replaced with an "integer".""" + path = parse(replace_token) + values = [match.value for match in path.find(pact_result.received_payload.message)] + for v in values: + assert compare_type(expected_type, v) + + +@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, +): + """the pact file will contain 1 message interaction.""" + assert len(pact_result.pact_data['messages']) == interaction_count + + +@then( + parsers.re( + r'the provider state "(?P[^"]+)" for the message ' + r'will contain the following parameters:\n(?P.+)', + re.DOTALL, + ), + converters={"parameters": parse_markdown_table}, +) +def the_provider_state_for_the_message_will_contain_the_following_parameters( + pact_interaction: PactInteraction, + pact_result: PactResult, + state: str, + parameters: list[dict[str, Any]], +): + """the provider state "a user exists" for the message will contain the following parameters: +| parameters | +| {"age":66,"name":"Test Guy","username":"Test"} |.""" + print( + f'The provider state "{state}" for the message will contain ' + f'the following parameters: {parameters}, got {pact_result}' + ) + provider_state_params = None + expected_params = json.loads(parameters[0]['parameters']) + for provider_state in pact_interaction.pact.get_provider_states(): + if provider_state['name'] == state: + provider_state_params = provider_state['params'] + break + # if we have provider_state_params, we found the expected provider state name + assert provider_state_params is not None + found = { k: False for k in expected_params.keys() } + for k, v in expected_params.items(): + for provider_state_param in provider_state_params: + if provider_state_param.get(k): + assert ast.literal_eval(provider_state_param[k]) == v + found[k] = True + break + assert all(found.values()) + +@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, +): + """the received message content type will be "application/json".""" + assert any([context.get('contentType') == content_type for context in pact_result.received_payload.context]) + +@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, +): + """the received message metadata will contain "ID" replaced with an "integer".""" + found = False + for metadata in pact_result.received_payload.context: + if metadata.get(key): + assert compare_type(expected_type, metadata[key]) + found = True + assert found + + +@then( + parsers.re( + r'the received message metadata will contain "(?P[^"]+)" == "(?P[^"\\]*(?:\\.[^"\\]*)*)"' + ) +) +def the_received_message_metadata_will_contain( + pact_result: PactResult, + key: str, + value: Any, +): + """the received message metadata will contain "Origin" == "Some Text".""" + found = False + if value.startswith('JSON: '): + value = value.replace('JSON: ', '') + value = value.replace('\\"', '"') + value = json.loads(value) + for metadata in pact_result.received_payload.context: + if metadata.get(key): + if isinstance(value, dict): + assert json.loads(metadata[key]) == value + elif NUM_RE.match(metadata[key]): + assert ast.literal_eval(metadata[key]) == value + else: + assert metadata[key] == value + found = True + assert found + + +@then( + parsers.re('the received message payload will contain the "(?P[^"]+)" JSON document') +) +def the_received_message_payload_will_contain_the_basic_json_document( + pact_result: PactResult, + json_doc: str +): + """the received message payload will contain the "basic" JSON document.""" + assert pact_result.received_payload.message == read_json(f'{json_doc}.json') + diff --git a/tests/v3/compatibility_suite/test_v4_message_consumer.py b/tests/v3/compatibility_suite/test_v4_message_consumer.py new file mode 100644 index 000000000..b93a6230a --- /dev/null +++ b/tests/v3/compatibility_suite/test_v4_message_consumer.py @@ -0,0 +1,171 @@ +"""Message consumer feature tests.""" +import json +import os +import pytest +from collections import namedtuple +from typing import Any + +from pytest_bdd import ( + given, + parsers, + scenario, + then, + when, +) + +from tests.v3.compatibility_suite.util import string_to_int +from pact.v3.pact import AsyncMessageInteraction, Pact + +PactInteraction = namedtuple("PactInteraction", ["pact", "interaction"]) + +TEST_PACT_FILE_DIRECTORY = os.path.join(os.path.dirname(__file__), 'pacts') + +@scenario( + 'definition/features/V4/message_consumer.feature', + 'Sets the type for the interaction' +) +def test_sets_the_type_for_the_interaction(): + """Sets the type for the interaction.""" + + +@scenario( + 'definition/features/V4/message_consumer.feature', + 'Supports adding comments' +) +def test_supports_adding_comments(): + """Supports adding comments.""" + + +@pytest.fixture(autouse=True) +def handle_pact_file_directory(): + if not os.path.exists(TEST_PACT_FILE_DIRECTORY): + os.mkdir(TEST_PACT_FILE_DIRECTORY) + yield + os.rmdir(TEST_PACT_FILE_DIRECTORY) + +@scenario( + 'definition/features/V4/message_consumer.feature', + 'Supports specifying a key for the interaction' +) +def test_supports_specifying_a_key_for_the_interaction(): + """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(): + """Supports specifying the interaction is pending.""" + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.re(r'a comment "(?P[^"]+)" is added to the message interaction') +) +def a_comment_is_added_to_the_message_interaction( + pact_interaction: PactInteraction, + comment: str +): + """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_http_interaction( + pact_interaction: PactInteraction, + key: str, +) -> None: + """A key is specified for the HTTP interaction.""" + pact_interaction.interaction.set_key(key) + + +@given( + 'a message interaction is being defined for a consumer test', + target_fixture='pact_interaction' +) +def a_message_interaction_is_being_defined_for_a_consumer_test(): + """a message integration is being defined for a consumer test.""" + pact = Pact("message_consumer", "message_provider") + pact.with_specification("V4") + yield PactInteraction(pact, pact.upon_receiving("a request", "Async")) + + +@given('the message interaction is marked as pending') +def the_message_interaction_is_marked_as_pending( + pact_interaction: PactInteraction +): + """the message interaction is marked as pending.""" + pact_interaction.interaction.set_pending(pending=True) + + +################################################################################ +## When +################################################################################ + + +@when( + 'the Pact file for the test is generated', + target_fixture='pact_data' +) +def the_pact_file_for_the_test_is_generated( + pact_interaction: PactInteraction +): + """the Pact file for the test is generated.""" + pact_interaction.pact.write_message_file(TEST_PACT_FILE_DIRECTORY, overwrite=True) + with open(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) as file: + yield json.load(file) + os.remove(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) + + +################################################################################ +## 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 + From c6dd880d71d8389b419b30994c6add5121175c55 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Wed, 15 May 2024 18:36:21 -0600 Subject: [PATCH 02/61] created MessagePact, cleaned up the verify method to use interaction iterator, other small tweaks --- pyproject.toml | 2 +- src/pact/v3/ffi.py | 37 +++- src/pact/v3/pact.py | 182 +++++++++--------- .../test_v3_message_consumer.py | 4 +- .../test_v4_message_consumer.py | 4 +- 5 files changed, 130 insertions(+), 99 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6521f9067..047d571a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,7 +161,7 @@ extra-dependencies = [ lint = "ruff check --output-format=full --show-fixes {args}" typecheck = "mypy {args:.}" format = "ruff format {args}" -test = "pytest tests/v3/compatibility_suite/test_v?_message_consumer.py {args}" +test = "pytest tests/ {args}" example = "pytest examples/ {args}" all = ["format", "lint", "typecheck", "test", "example"] docs = "mkdocs serve {args}" diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index 79747cef8..f97389cdb 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -498,7 +498,35 @@ def port(self) -> int: return self._ref -class PactInteraction: ... +class PactInteraction: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Pact Interaction. + + Args: + ptr: + CFFI data structure. + """ + if ffi.typeof(ptr).cname != "struct PactInteraction *": + msg = ( + "ptr must be a struct PactInteraction, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "PactInteraction" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"PactInteraction({self._ptr!r})" + class PactInteractionIterator: @@ -543,7 +571,7 @@ def __del__(self) -> None: """ pact_interaction_iter_delete(self) - def __iter__(self) -> Self: + def __iter__(self) -> PactInteractionIterator: return self def __next__(self) -> PactInteraction: @@ -1736,7 +1764,7 @@ def pact_model_interaction_iterator(pact: Pact) -> PactInteractionIterator: # Errors On any error, this function will return a NULL pointer. """ - raise NotImplementedError + return PactInteractionIterator(lib.pactffi_pact_model_interaction_iterator(pact)) def pact_spec_version(pact: Pact) -> PactSpecification: @@ -3457,7 +3485,6 @@ def pact_interaction_iter_next(iter: PactInteractionIterator) -> PactInteraction ptr = lib.pactffi_pact_interaction_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration - raise NotImplementedError return PactInteraction(ptr) @@ -5233,7 +5260,7 @@ def pact_handle_to_pointer(pact: PactHandle) -> Pact: The returned Pact model must be freed with the `pactffi_pact_model_delete` function when no longer needed. """ - raise NotImplementedError + return lib.pactffi_pact_handle_to_pointer(pact._ref) def new_interaction(pact: PactHandle, description: str) -> InteractionHandle: diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index cc72c4489..76731e920 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -87,7 +87,7 @@ logger = logging.getLogger(__name__) -class Pact: +class BasePact: """ A Pact between a consumer and a provider. @@ -277,98 +277,15 @@ def upon_receiving( Type of interaction. Defaults to `HTTP`. This must be one of `HTTP`, `Async`, or `Sync`. """ - newInteraction = None if interaction == "HTTP": - newInteraction = HttpInteraction(self._handle, description) + return HttpInteraction(self._handle, description) elif interaction == "Async": - newInteraction = AsyncMessageInteraction(self._handle, description) + return AsyncMessageInteraction(self._handle, description) elif interaction == "Sync": - newInteraction = SyncMessageInteraction(self._handle, description) - else: - msg = f"Invalid interaction type: {interaction}" - raise ValueError(msg) - self._interactions.add(newInteraction) - return newInteraction - - def verify(self, handler) -> list[dict[str, Any]]: - processed_messages = [] - for interaction in self._interactions: - msg_iter = pact.v3.ffi.pact_handle_get_message_iter(self._handle) - for msg in msg_iter: - processed_messages.append({ - "description": msg.description, - "contents": msg.contents, - "metadata": msg.metadata, - }) - try: - async_message = context = {} - if msg.contents is not None: - async_message = json.loads(msg.contents) - if msg.metadata is not None: - context = msg.metadata - handler(async_message, context) - except Exception as e: - raise e - return processed_messages - - 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`. + return SyncMessageInteraction(self._handle, description) - 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: - A [`PactServer`][pact.v3.pact.PactServer] instance. - """ - return PactServer( - self._handle, - addr, - port, - transport, - transport_config, - raises=raises, - verbose=verbose, - ) + msg = f"Invalid interaction type: {interaction}" + raise ValueError(msg) def messages(self) -> pact.v3.ffi.PactMessageIterator: """ @@ -498,6 +415,71 @@ def write_message_file( overwrite=overwrite, ) + +class Pact(BasePact): + + 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: + A [`PactServer`][pact.v3.pact.PactServer] instance. + """ + return PactServer( + self._handle, + addr, + port, + transport, + transport_config, + raises=raises, + verbose=verbose, + ) + + + +class MessagePact(BasePact): def get_provider_states(self): """ Get the provider states for the interaction. @@ -514,6 +496,28 @@ def get_provider_states(self): }) return provider_state_data + def verify(self, handler) -> list[dict[str, Any]]: + processed_messages = [] + _mutable_pact = pact.v3.ffi.pact_handle_to_pointer(self._handle) + for interaction in pact.v3.ffi.pact_model_interaction_iterator(_mutable_pact): + msg_iter = pact.v3.ffi.pact_handle_get_message_iter(self._handle) + for msg in msg_iter: + processed_messages.append({ + "description": msg.description, + "contents": msg.contents, + "metadata": msg.metadata, + }) + try: + async_message = context = {} + if msg.contents is not None: + async_message = json.loads(msg.contents) + if msg.metadata is not None: + context = msg.metadata + handler(async_message, context) + except Exception as e: + raise e + return processed_messages + class MismatchesError(Exception): """ diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py index 555c7918e..d62865bb9 100644 --- a/tests/v3/compatibility_suite/test_v3_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -19,7 +19,7 @@ when, ) -from pact.v3.pact import AsyncMessageInteraction, Pact +from pact.v3.pact import AsyncMessageInteraction, MessagePact as Pact from tests.v3.compatibility_suite.util import parse_markdown_table logger = logging.getLogger(__name__) @@ -287,7 +287,7 @@ def the_message_is_successfully_processed( def handler(async_message, context): received_payload['data'] = ReceivedPayload(async_message, context) pact_interaction.pact.verify(handler) - pact_interaction.pact.write_message_file(TEST_PACT_FILE_DIRECTORY, overwrite=True) + pact_interaction.pact.write_file(TEST_PACT_FILE_DIRECTORY, overwrite=True) with open(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) as file: yield PactResult(received_payload['data'], json.load(file), None) os.remove(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) diff --git a/tests/v3/compatibility_suite/test_v4_message_consumer.py b/tests/v3/compatibility_suite/test_v4_message_consumer.py index b93a6230a..1ebaef9c4 100644 --- a/tests/v3/compatibility_suite/test_v4_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v4_message_consumer.py @@ -14,7 +14,7 @@ ) from tests.v3.compatibility_suite.util import string_to_int -from pact.v3.pact import AsyncMessageInteraction, Pact +from pact.v3.pact import AsyncMessageInteraction, MessagePact as Pact PactInteraction = namedtuple("PactInteraction", ["pact", "interaction"]) @@ -116,7 +116,7 @@ def the_pact_file_for_the_test_is_generated( pact_interaction: PactInteraction ): """the Pact file for the test is generated.""" - pact_interaction.pact.write_message_file(TEST_PACT_FILE_DIRECTORY, overwrite=True) + pact_interaction.pact.write_file(TEST_PACT_FILE_DIRECTORY, overwrite=True) with open(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) as file: yield json.load(file) os.remove(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) From 2891e4a35a9a28a5ab10b161436ddb5c1dc282f0 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Wed, 15 May 2024 18:42:04 -0600 Subject: [PATCH 03/61] removing write_message_file method as it is unecessary --- src/pact/v3/pact.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index 76731e920..451a64143 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -383,38 +383,6 @@ def write_file( overwrite=overwrite, ) - def write_message_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.v3.ffi.write_message_pact_file( - self._handle, - directory, - overwrite=overwrite, - ) - class Pact(BasePact): From 76e9ff2909df94be262872addd8d97764b8c9322 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Wed, 22 May 2024 14:31:05 -0600 Subject: [PATCH 04/61] lint cleanup --- .../test_v3_message_consumer.py | 495 ++++++++++-------- 1 file changed, 286 insertions(+), 209 deletions(-) diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py index d62865bb9..08faa4ca6 100644 --- a/tests/v3/compatibility_suite/test_v3_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -1,15 +1,13 @@ +"""V3 Message consumer feature tests.""" from __future__ import annotations +import ast import json import logging import re -import os -import pytest -from collections import namedtuple -from tempfile import TemporaryDirectory -from typing import Any, Generator +from pathlib import Path +from typing import Any, Generator, NamedTuple -import ast from jsonpath_ng import parse from pytest_bdd import ( given, @@ -19,91 +17,100 @@ when, ) -from pact.v3.pact import AsyncMessageInteraction, MessagePact as Pact -from tests.v3.compatibility_suite.util import parse_markdown_table +from pact.v3.pact import AsyncMessageInteraction +from pact.v3.pact import MessagePact as Pact +from tests.v3.compatibility_suite.util import FIXTURES_ROOT, parse_markdown_table logger = logging.getLogger(__name__) -PactInteraction = namedtuple("PactInteraction", ["pact", "interaction"]) -PactResult = namedtuple('PactResult', ['received_payload', 'pact_data', 'error']) -ReceivedPayload = namedtuple('ReceivedPayload', ['message', 'context']) +class PactInteraction(NamedTuple): + """Holder class for Pact and Interaction.""" + pact: Pact + interaction: AsyncMessageInteraction -NUM_RE = re.compile(r'^-?[.0-9]+$') -TEST_PACT_FILE_DIRECTORY = os.path.join(os.path.dirname(__file__), 'pacts') +class PactResult(NamedTuple): + """Holder class for Pact Result objects.""" + received_payload: dict[str, Any] + pact_data: dict[str, Any] | None + error: str | None -def read_json(file): - with open(os.path.join(os.path.dirname(__file__),'definition','fixtures',file)) as f: - return json.loads(f.read()) +class ReceivedPayload(NamedTuple): + """Holder class for Message Received Payload.""" + message: any + context: any -def compare_type(expected_type, t): - if expected_type == 'integer': - try: - int(t) - return True - except ValueError: - return False - else: - raise ValueError(f'Unknown type: {expected_type}') +class UnknownTypeError(Exception): + """Unknown type error.""" + def __init__(self, expected_type: str) -> None: + """Initialize the UnknownTypeError.""" + super().__init__(f"Unknown type: {expected_type}") + +class UnknownGeneratorCategoryError(Exception): + """Unknown type error.""" + def __init__(self, generator_category: str) -> None: + """Initialize the UnknownGeneratorCategoryError.""" + super().__init__(f"Unknown generator category: {generator_category}") -@pytest.fixture(autouse=True) -def handle_pact_file_directory(): - if not os.path.exists(TEST_PACT_FILE_DIRECTORY): - os.mkdir(TEST_PACT_FILE_DIRECTORY) - yield - os.rmdir(TEST_PACT_FILE_DIRECTORY) +class TestFailedError(Exception): + """Test failed error.""" + def __init__(self) -> None: + """Initialize the TestFailedError.""" + super().__init__("Test failed") + +NUM_RE = re.compile(r"^-?[.0-9]+$") @scenario( - 'definition/features/V3/message_consumer.feature', - 'Supports arbitrary message metadata' + "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 data for provider states' + "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 specifying provider states' + "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 the use of generators with message metadata' + "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.""" @scenario( - 'definition/features/V3/message_consumer.feature', - 'Supports the use of generators with the message body' + "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', - 'When all messages are successfully processed' + "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' + "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.""" @@ -115,22 +122,21 @@ def test_when_not_all_messages_are_successfully_processed() -> None: @given( - 'a message integration is being defined for a consumer test', - target_fixture='pact_interaction' + "a message integration is being defined for a consumer test", + target_fixture="pact_interaction" ) def a_message_integration_is_being_defined_for_a_consumer_test() -> ( Generator[tuple[Pact, AsyncMessageInteraction], Any, None] ): - """a message integration is being defined for a consumer test.""" - pact = Pact("message_consumer", "message_provider") + """A message integration is being defined for a consumer test.""" + pact = Pact("consumer", "provider") pact.with_specification("V3") yield PactInteraction(pact, pact.upon_receiving("a request", "Async")) -@given('a message is defined') -def a_message_is_defined(pact_interaction: PactInteraction): - """a message is defined.""" - print('A message is being defined. This is not yet implemented') +@given("a message is defined") +def _a_message_is_defined() -> None: + """A message is defined.""" @given( @@ -145,8 +151,8 @@ def a_provider_state_for_the_message_is_specified_with_the_following_data( pact_interaction: PactInteraction, state: str, table: list[dict[str, Any]] -): - """a provider state for the message is specified with the following data:""" +) -> None: + """A provider state for the message is specified with the following data.""" for parameters in table: state_params = { k: ast.literal_eval(v) for k, v in parameters.items() } pact_interaction.interaction.given(state, parameters=state_params) @@ -158,86 +164,56 @@ def a_provider_state_for_the_message_is_specified_with_the_following_data( def a_provider_state_for_the_message_is_specified( pact_interaction: PactInteraction, state: str, -): - """a provider state for the message is specified.""" +) -> None: + """A provider state for the message is specified.""" pact_interaction.interaction.given(state) @given( - parsers.re('the message contains the following metadata:\n(?P
.+)', re.DOTALL), + parsers.re( + "the message contains the following " + "metadata:\n(?P
.+)", re.DOTALL + ), converters={"table": parse_markdown_table}, ) def the_message_contains_the_following_metadata( pact_interaction: PactInteraction, table: list[dict[str, Any]] -): - """the message contains the following metadata:""" +) -> None: + """The message contains the following metadata.""" for metadata in table: - if metadata.get('value','').startswith('JSON: '): - metadata['value'] = metadata['value'].replace('JSON:', '') - pact_interaction.interaction.with_metadata({metadata['key']: metadata['value']}) + if metadata.get("value","").startswith("JSON: "): + metadata["value"] = metadata["value"].replace("JSON:", "") + pact_interaction.interaction.with_metadata({metadata["key"]: metadata["value"]}) @given( - parsers.re('the message is configured with the following:\n(?P
.+)', re.DOTALL), + parsers.re( + "the message is configured with the following:\n" + "(?P
.+)", re.DOTALL + ), converters={"table": parse_markdown_table}, ) def the_message_is_configured_with_the_following( pact_interaction: PactInteraction, table: list[dict[str, Any]], -): - """the message is configured with the following:""" - body_json = generator_json = metadata_json = {} - for entry in table: - for k, v in entry.items(): - if k == 'generators': - if v.startswith('JSON: '): - generator_json = json.loads(v.replace('JSON:', '')) - else: - generator_json = read_json(v) - elif k == 'body': - if v.startswith('file: '): - file = v.replace('file: ', '') - body_json = read_json(file) - elif k == 'metadata': - metadata_json = json.loads(v) +) -> None: + """The message is configured with the following.""" + body_json, generator_json, metadata_json = _build_message_data(table) if generator_json: - category = list(generator_json.keys())[0] - if category == 'body': - for k, v in generator_json['body'].items(): - path = parse(k) - current_values = [match.value for match in path.find(body_json)] - matches = path.find(body_json) - for i, match in enumerate(matches): - generator_type = v['type'] - del v['type'] - replacement_value = { - 'value': current_values[i], - 'pact:matcher:type': 'notEmpty', - 'pact:generator:type': generator_type, - } - replacement_value.update(v) - matches[i].full_path.update(body_json, replacement_value) - elif category == 'metadata': - for k in generator_json['metadata'].keys(): - metadata = metadata_json[k] - if not isinstance(metadata, dict): - metadata = { 'value': metadata } - metadata_json[k] = metadata - generator_data = generator_json['metadata'][k] - metadata.update({ - 'pact:generator:type': generator_data['type'], - 'pact:matcher:type': 'notEmpty', - }) - del generator_data['type'] - metadata.update(generator_data) + category = next(iter(generator_json.keys())) + if category == "body": + _build_body_generator(generator_json, body_json) + elif category == "metadata": + _build_metadata_generator(generator_json, metadata_json) else: - raise ValueError(f'Unknown generator category: {category}') + raise UnknownGeneratorCategoryError(category) pact_interaction.interaction.with_content(body_json) for k, v in metadata_json.items(): + v_str = v if isinstance(v, dict): - v = json.dumps(v) - pact_interaction.interaction.with_metadata({k: str(v)}) + v_str = json.dumps(v) + pact_interaction.interaction.with_metadata({k: str(v_str)}) @given( @@ -246,9 +222,9 @@ def the_message_is_configured_with_the_following( def the_message_payload_contains_the_basic_json_document( pact_interaction: PactInteraction, json_doc: str -): - """the message payload contains the "basic" JSON document.""" - pact_interaction.interaction.with_content(read_json(f'{json_doc}.json')) +) -> None: + """The message payload contains the "basic" JSON document.""" + pact_interaction.interaction.with_content(read_json(f"{json_doc}.json")) ################################################################################ @@ -262,35 +238,43 @@ def the_message_payload_contains_the_basic_json_document( ) def the_message_is_not_successfully_processed_with_an_exception( pact_interaction: PactInteraction -): - """the message is NOT successfully processed with a "Test failed" exception.""" +) -> None: + """The message is NOT successfully processed with a "Test failed" exception.""" # using a dict here because it's mutable - received_payload = {'data': None} - def fail(async_message, context): - received_payload['data'] = ReceivedPayload(async_message, context) - raise Exception('Test failed') + received_payload = {"data": None} + def fail( + async_message: str | dict[any: any], context: dict[any: any] + ) -> None: + received_payload["data"] = ReceivedPayload(async_message, context) + raise TestFailedError try: pact_interaction.pact.verify(fail) - except Exception as e: - return PactResult(received_payload['data'], None, e) + except Exception as e: # noqa: BLE001 + return PactResult(received_payload["data"], None, e) @when( - 'the message is successfully processed', + "the message is successfully processed", target_fixture="pact_result" ) def the_message_is_successfully_processed( - pact_interaction: PactInteraction -): - """the message is successfully processed.""" - received_payload = {'data': None} - def handler(async_message, context): - received_payload['data'] = ReceivedPayload(async_message, context) + pact_interaction: PactInteraction, + temp_dir: Path +) -> None: + """The message is successfully processed.""" + received_payload = {"data": None} + def handler( + async_message: str | dict[Any, Any], + context: dict[Any, Any], + ) -> None: + received_payload["data"] = ReceivedPayload(async_message, context) pact_interaction.pact.verify(handler) - pact_interaction.pact.write_file(TEST_PACT_FILE_DIRECTORY, overwrite=True) - with open(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) as file: - yield PactResult(received_payload['data'], json.load(file), None) - os.remove(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) + (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) + pact_interaction.pact.write_file(temp_dir / "pacts") + with ( + temp_dir / "pacts" / "consumer-provider.json" + ).open() as file: + yield PactResult(received_payload["data"], json.load(file), None) ################################################################################ @@ -298,16 +282,24 @@ def handler(async_message, context): ################################################################################ -@then('a Pact file for the message interaction will NOT have been written') -def a_pact_file_for_the_message_interaction_will_not_have_been_written(): - """a Pact file for the message interaction will NOT have been written.""" - assert not os.path.exists(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) +@then("a Pact file for the message interaction will NOT have been written") +def a_pact_file_for_the_message_interaction_will_not_have_been_written( + temp_dir: Path +) -> None: + """A Pact file for the message interaction will NOT have been written.""" + assert not Path( + temp_dir / "pacts" / "consumer-provider.json" + ).exists() -@then('a Pact file for the message interaction will have been written') -def a_pact_file_for_the_message_interaction_will_have_been_written(): - """a Pact file for the message interaction will have been written.""" - assert os.path.exists(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) +@then("a Pact file for the message interaction will have been written") +def a_pact_file_for_the_message_interaction_will_have_been_written( + temp_dir: Path +) -> None: + """A Pact file for the message interaction will have been written.""" + assert Path( + temp_dir / "pacts" / "consumer-provider.json" + ).exists() @then( @@ -316,75 +308,88 @@ def a_pact_file_for_the_message_interaction_will_have_been_written(): def the_consumer_test_error_will_be_test_failed( pact_result: PactResult, error: str, -): - """the consumer test error will be "Test failed".""" +) -> None: + """The consumer test error will be "Test failed".""" assert str(pact_result.error) == error -@then('the consumer test will have failed') +@then("the consumer test will have failed") def the_consumer_test_will_have_failed( pact_result: PactResult -): - """the consumer test will have failed.""" - assert type(pact_result.error) == Exception +) -> None: + """The consumer test will have failed.""" + assert type(pact_result.error) == TestFailedError assert pact_result.pact_data is None -@then('the consumer test will have passed') +@then("the consumer test will have passed") def the_consumer_test_will_have_passed( pact_result: PactResult -): - """the consumer test will have passed.""" +) -> None: + """The consumer test will have passed.""" assert pact_result.error is None assert pact_result.pact_data is not None @then( - parsers.re(r'the first message in the Pact file will contain provider state "(?P[^"]+)"') + parsers.re( + r'the first message in the Pact file will contain ' + 'provider state "(?P[^"]+)"' + ) ) def the_first_message_in_the_pact_file_will_contain_provider_state( pact_result: PactResult, state: str, -): - """the first message in the Pact file will contain provider state.""" +) -> None: + """The first message in the Pact file will contain provider state.""" assert state in [ - provider_state['name'] - for provider_state in pact_result.pact_data['messages'][0]['providerStates'] + provider_state["name"] + for provider_state in pact_result.pact_data["messages"][0]["providerStates"] ] @then( - parsers.re(r'the first message in the pact file content type will be "(?P[^"]+)"') + parsers.re( + r'the first message in the pact file content type ' + 'will be "(?P[^"]+)"' + ) ) def the_first_message_in_the_pact_file_content_type_will_be( pact_result: PactResult, content_type: str, -): - """the first message in the pact file content type will be "application/json".""" - assert pact_result.pact_data['messages'][0]['metadata']['contentType'] == content_type +) -> None: + """The first message in the pact file content type will be "application/json".""" + messages: list[dict[str, Any]] = pact_result.pact_data["messages"] + assert messages[0]["metadata"]["contentType"] == content_type @then( - parsers.re(r'the first message in the pact file will contain (?P[0-9]+) provider states?'), + parsers.re( + r"the first message in the pact file will contain " + "(?P[0-9]+) provider states?" + ), converters={"state_count": int}, ) def the_first_message_in_the_pact_file_will_contain( pact_result: PactResult, state_count: int, -): - """the first message in the pact file will contain 1 provider state.""" - assert len(pact_result.pact_data['messages'][0]['providerStates']) == state_count +) -> None: + """The first message in the pact file will contain 1 provider state.""" + assert len(pact_result.pact_data["messages"][0]["providerStates"]) == state_count @then( - parsers.re('the first message in the pact file will contain the "(?P[^"]+)" document') + parsers.re( + 'the first message in the pact file will contain ' + 'the "(?P[^"]+)" document' + ) ) def the_first_message_in_the_pact_file_will_contain_the_basic_json_document( pact_result: PactResult, json_doc: str -): - """the first message in the pact file will contain the "basic.json" document.""" - assert pact_result.pact_data['messages'][0]['contents'] == read_json(json_doc) +) -> None: + """The first message in the pact file will contain the "basic.json" document.""" + assert pact_result.pact_data["messages"][0]["contents"] == read_json(json_doc) @then( @@ -396,14 +401,14 @@ def the_first_message_in_the_pact_file_will_contain_the_basic_json_document( def the_first_message_in_the_pact_file_will_contain_the_message_metadata( pact_result: PactResult, key: str, - value: Any, -): - """the first message in the pact file will contain the message metadata "Origin" == "Some Text".""" - if value.startswith('JSON: '): - value = value.replace('JSON: ', '') + value: any, +) -> 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) - assert pact_result.pact_data['messages'][0]['metadata'][key] == value + assert pact_result.pact_data["messages"][0]["metadata"][key] == value @then( @@ -416,8 +421,8 @@ def the_message_contents_will_have_been_replaced_with( pact_result: PactResult, replace_token: str, expected_type: str, -): - """the message contents for "$.one" will have been replaced with an "integer".""" +) -> None: + """The message contents for "$.one" will have been replaced with an "integer".""" path = parse(replace_token) values = [match.value for match in path.find(pact_result.received_payload.message)] for v in values: @@ -425,14 +430,16 @@ def the_message_contents_will_have_been_replaced_with( @then( - parsers.parse('the pact file will contain {interaction_count:d} message interaction') + 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, -): - """the pact file will contain 1 message interaction.""" - assert len(pact_result.pact_data['messages']) == interaction_count +) -> None: + """The pact file will contain 1 message interaction.""" + assert len(pact_result.pact_data["messages"]) == interaction_count @then( @@ -445,26 +452,19 @@ def the_pact_file_will_contain_message_interaction( ) def the_provider_state_for_the_message_will_contain_the_following_parameters( pact_interaction: PactInteraction, - pact_result: PactResult, state: str, parameters: list[dict[str, Any]], -): - """the provider state "a user exists" for the message will contain the following parameters: -| parameters | -| {"age":66,"name":"Test Guy","username":"Test"} |.""" - print( - f'The provider state "{state}" for the message will contain ' - f'the following parameters: {parameters}, got {pact_result}' - ) +) -> None: + """The provider state for the message will contain the following parameters.""" provider_state_params = None - expected_params = json.loads(parameters[0]['parameters']) + expected_params = json.loads(parameters[0]["parameters"]) for provider_state in pact_interaction.pact.get_provider_states(): - if provider_state['name'] == state: - provider_state_params = provider_state['params'] + if provider_state["name"] == state: + provider_state_params = provider_state["params"] break # if we have provider_state_params, we found the expected provider state name assert provider_state_params is not None - found = { k: False for k in expected_params.keys() } + found = { k: False for k in expected_params } for k, v in expected_params.items(): for provider_state_param in provider_state_params: if provider_state_param.get(k): @@ -479,22 +479,25 @@ def the_provider_state_for_the_message_will_contain_the_following_parameters( def the_received_message_content_type_will_be( pact_result: PactResult, content_type: str, -): - """the received message content type will be "application/json".""" - assert any([context.get('contentType') == content_type for context in pact_result.received_payload.context]) +) -> None: + """The received message content type will be "application/json".""" + assert any( + context.get("contentType") == content_type + for context in pact_result.received_payload.context + ) @then( parsers.re( r'the received message metadata will contain "(?P[^"]+)" ' - r'replaced with an? "(?P[^"]+)"' + 'replaced with an? "(?P[^"]+)"' ) ) def the_received_message_metadata_will_contain_replaced_with( pact_result: PactResult, key: str, expected_type: str, -): - """the received message metadata will contain "ID" replaced with an "integer".""" +) -> None: + """The received message metadata will contain "ID" replaced with an "integer".""" found = False for metadata in pact_result.received_payload.context: if metadata.get(key): @@ -505,18 +508,19 @@ def the_received_message_metadata_will_contain_replaced_with( @then( parsers.re( - r'the received message metadata will contain "(?P[^"]+)" == "(?P[^"\\]*(?:\\.[^"\\]*)*)"' + r'the received message metadata will contain ' + r'"(?P[^"]+)" == "(?P[^"\\]*(?:\\.[^"\\]*)*)"' ) ) def the_received_message_metadata_will_contain( pact_result: PactResult, key: str, - value: Any, -): - """the received message metadata will contain "Origin" == "Some Text".""" + value: any, +) -> None: + """The received message metadata will contain "Origin" == "Some Text".""" found = False - if value.startswith('JSON: '): - value = value.replace('JSON: ', '') + if value.startswith("JSON: "): + value = value.replace("JSON: ", "") value = value.replace('\\"', '"') value = json.loads(value) for metadata in pact_result.received_payload.context: @@ -532,12 +536,85 @@ def the_received_message_metadata_will_contain( @then( - parsers.re('the received message payload will contain the "(?P[^"]+)" JSON document') + parsers.re( + r'the received message payload will contain ' + 'the "(?P[^"]+)" JSON document' + ) ) def the_received_message_payload_will_contain_the_basic_json_document( pact_result: PactResult, json_doc: str -): - """the received message payload will contain the "basic" JSON document.""" - assert pact_result.received_payload.message == read_json(f'{json_doc}.json') +) -> None: + """The received message payload will contain the "basic" JSON document.""" + assert pact_result.received_payload.message == read_json(f"{json_doc}.json") + + +def read_json(file: str) -> dict[str, Any]: + with Path(FIXTURES_ROOT / file).open() as f: + return json.loads(f.read()) + + +def compare_type(expected_type: str, t: str | int | None) -> bool: + if expected_type == "integer": + try: + int(t) + except ValueError: + return False + return True + raise UnknownTypeError(expected_type) + +def _build_message_data( + table: list[dict[str, Any]] +) -> (dict[str, Any], dict[str, Any], dict[str, Any]): + body_json = generator_json = metadata_json = {} + for entry in table: + for k, v in entry.items(): + if k == "generators": + if v.startswith("JSON: "): + generator_json = json.loads(v.replace("JSON:", "")) + else: + generator_json = read_json(v) + elif k == "body": + if v.startswith("file: "): + file = v.replace("file: ", "") + body_json = read_json(file) + elif k == "metadata": + metadata_json = json.loads(v) + return body_json, generator_json, metadata_json + +def _build_body_generator( + generator_json: dict[str, Any], + body_json: dict[str, Any] +) -> None: + for k, v in generator_json["body"].items(): + path = parse(k) + current_values = [match.value for match in path.find(body_json)] + matches = path.find(body_json) + for i, _ in enumerate(matches): + generator_type = v["type"] + del v["type"] + replacement_value = { + "value": current_values[i], + "pact:matcher:type": "notEmpty", + "pact:generator:type": generator_type, + } + replacement_value.update(v) + matches[i].full_path.update(body_json, replacement_value) + +def _build_metadata_generator( + generator_json: dict[str, Any], + metadata_json: dict[str, Any] +) -> None: + for k in generator_json["metadata"]: + metadata = metadata_json[k] + if not isinstance(metadata, dict): + metadata = { "value": metadata } + metadata_json[k] = metadata + generator_data = generator_json["metadata"][k] + metadata.update({ + "pact:generator:type": generator_data["type"], + "pact:matcher:type": "notEmpty", + }) + del generator_data["type"] + metadata.update(generator_data) From 1e62a37358fecfd222da8fef18541fd3aae22ba9 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Wed, 22 May 2024 14:32:58 -0600 Subject: [PATCH 05/61] lint cleanup --- .../test_v4_message_consumer.py | 94 ++++++++++--------- 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/tests/v3/compatibility_suite/test_v4_message_consumer.py b/tests/v3/compatibility_suite/test_v4_message_consumer.py index 1ebaef9c4..978e3d3c3 100644 --- a/tests/v3/compatibility_suite/test_v4_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v4_message_consumer.py @@ -1,9 +1,8 @@ """Message consumer feature tests.""" +from __future__ import annotations + import json -import os -import pytest -from collections import namedtuple -from typing import Any +from typing import TYPE_CHECKING, Any, NamedTuple from pytest_bdd import ( given, @@ -13,49 +12,49 @@ when, ) +from pact.v3.pact import AsyncMessageInteraction +from pact.v3.pact import MessagePact as Pact from tests.v3.compatibility_suite.util import string_to_int -from pact.v3.pact import AsyncMessageInteraction, MessagePact as Pact -PactInteraction = namedtuple("PactInteraction", ["pact", "interaction"]) +if TYPE_CHECKING: + from pathlib import Path + + +class PactInteraction(NamedTuple): + """Holder class for Pact and Interaction.""" + pact: Pact + interaction: AsyncMessageInteraction -TEST_PACT_FILE_DIRECTORY = os.path.join(os.path.dirname(__file__), 'pacts') @scenario( - 'definition/features/V4/message_consumer.feature', - 'Sets the type for the interaction' + "definition/features/V4/message_consumer.feature", + "Sets the type for the interaction" ) -def test_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 adding comments' + "definition/features/V4/message_consumer.feature", + "Supports adding comments" ) -def test_supports_adding_comments(): +def test_supports_adding_comments() -> None: """Supports adding comments.""" -@pytest.fixture(autouse=True) -def handle_pact_file_directory(): - if not os.path.exists(TEST_PACT_FILE_DIRECTORY): - os.mkdir(TEST_PACT_FILE_DIRECTORY) - yield - os.rmdir(TEST_PACT_FILE_DIRECTORY) - @scenario( - 'definition/features/V4/message_consumer.feature', - 'Supports specifying a key for the interaction' + "definition/features/V4/message_consumer.feature", + "Supports specifying a key for the interaction" ) -def test_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' + "definition/features/V4/message_consumer.feature", + "Supports specifying the interaction is pending" ) -def test_supports_specifying_the_interaction_is_pending(): +def test_supports_specifying_the_interaction_is_pending() -> None: """Supports specifying the interaction is pending.""" @@ -70,12 +69,14 @@ def test_supports_specifying_the_interaction_is_pending(): def a_comment_is_added_to_the_message_interaction( pact_interaction: PactInteraction, comment: str -): - """a comment "{comment}" is added to the message interaction.""" +) -> 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')) +@given(parsers.re( + r'a key of "(?P[^"]+)" is specified for the message interaction') +) def a_key_is_specified_for_the_http_interaction( pact_interaction: PactInteraction, key: str, @@ -85,21 +86,21 @@ def a_key_is_specified_for_the_http_interaction( @given( - 'a message interaction is being defined for a consumer test', - target_fixture='pact_interaction' + "a message interaction is being defined for a consumer test", + target_fixture="pact_interaction" ) -def a_message_interaction_is_being_defined_for_a_consumer_test(): - """a message integration is being defined for a consumer test.""" - pact = Pact("message_consumer", "message_provider") +def a_message_interaction_is_being_defined_for_a_consumer_test() -> None: + """A message integration is being defined for a consumer test.""" + pact = Pact("consumer", "provider") pact.with_specification("V4") yield PactInteraction(pact, pact.upon_receiving("a request", "Async")) -@given('the message interaction is marked as pending') +@given("the message interaction is marked as pending") def the_message_interaction_is_marked_as_pending( pact_interaction: PactInteraction -): - """the message interaction is marked as pending.""" +) -> None: + """The message interaction is marked as pending.""" pact_interaction.interaction.set_pending(pending=True) @@ -109,17 +110,20 @@ def the_message_interaction_is_marked_as_pending( @when( - 'the Pact file for the test is generated', - target_fixture='pact_data' + "the Pact file for the test is generated", + target_fixture="pact_data" ) def the_pact_file_for_the_test_is_generated( - pact_interaction: PactInteraction -): - """the Pact file for the test is generated.""" - pact_interaction.pact.write_file(TEST_PACT_FILE_DIRECTORY, overwrite=True) - with open(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) as file: + pact_interaction: PactInteraction, + temp_dir: Path +) -> None: + """The Pact file for the test is generated.""" + (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) + pact_interaction.pact.write_file(temp_dir / "pacts") + with ( + temp_dir / "pacts" / "consumer-provider.json" + ).open() as file: yield json.load(file) - os.remove(os.path.join(TEST_PACT_FILE_DIRECTORY, 'message_consumer-message_provider.json')) ################################################################################ From d0daa4808e800297e9ebe4f4a05e164dc54d4a6b Mon Sep 17 00:00:00 2001 From: valkolovos Date: Wed, 22 May 2024 14:44:15 -0600 Subject: [PATCH 06/61] revert change that shouldn't have been committed --- examples/tests/test_01_provider_fastapi.py | 3 --- examples/tests/test_01_provider_flask.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/examples/tests/test_01_provider_fastapi.py b/examples/tests/test_01_provider_fastapi.py index bea18f23d..7ccd3128a 100644 --- a/examples/tests/test_01_provider_fastapi.py +++ b/examples/tests/test_01_provider_fastapi.py @@ -36,8 +36,6 @@ from examples.src.fastapi import app from pact import Verifier -import time - PROVIDER_URL = URL("http://localhost:8080") @@ -147,7 +145,6 @@ def test_against_broker(broker: URL, verifier: Verifier) -> None: For an example of the consumer's contract, see the consumer's tests. """ - time.sleep(3) code, _ = verifier.verify_with_broker( broker_url=str(broker), # Despite the auth being set in the broker URL, we still need to pass diff --git a/examples/tests/test_01_provider_flask.py b/examples/tests/test_01_provider_flask.py index 2a5430180..ba5c39d43 100644 --- a/examples/tests/test_01_provider_flask.py +++ b/examples/tests/test_01_provider_flask.py @@ -35,8 +35,6 @@ from examples.src.flask import app from pact import Verifier -import time - PROVIDER_URL = URL("http://localhost:8080") @@ -135,7 +133,6 @@ def test_against_broker(broker: URL, verifier: Verifier) -> None: For an example of the consumer's contract, see the consumer's tests. """ - time.sleep(3) code, _ = verifier.verify_with_broker( broker_url=str(broker), # Despite the auth being set in the broker URL, we still need to pass From 3d7dc6620dff8d597d2beb8111b207c18f5e0e4b Mon Sep 17 00:00:00 2001 From: valkolovos Date: Thu, 23 May 2024 08:10:07 -0600 Subject: [PATCH 07/61] moving message pact verification to interaction --- examples/tests/test_01_provider_fastapi.py | 2 + examples/tests/test_01_provider_flask.py | 2 + examples/tests/test_04_v3_message_consumer.py | 145 ++++++++++++++++++ src/pact/v3/ffi.py | 122 +++++++++------ src/pact/v3/interaction/__init__.py | 3 +- .../interaction/_async_message_interaction.py | 50 ++++-- src/pact/v3/interaction/_base.py | 38 +++++ src/pact/v3/pact.py | 89 ++++++----- .../test_v3_message_consumer.py | 32 ++-- 9 files changed, 364 insertions(+), 119 deletions(-) create mode 100644 examples/tests/test_04_v3_message_consumer.py diff --git a/examples/tests/test_01_provider_fastapi.py b/examples/tests/test_01_provider_fastapi.py index 7ccd3128a..ddcaeb9b2 100644 --- a/examples/tests/test_01_provider_fastapi.py +++ b/examples/tests/test_01_provider_fastapi.py @@ -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 @@ -145,6 +146,7 @@ def test_against_broker(broker: URL, verifier: Verifier) -> None: For an example of the consumer's contract, see the consumer's tests. """ + time.sleep(2) # give the broker time to start code, _ = verifier.verify_with_broker( broker_url=str(broker), # Despite the auth being set in the broker URL, we still need to pass diff --git a/examples/tests/test_01_provider_flask.py b/examples/tests/test_01_provider_flask.py index ba5c39d43..e26feddcc 100644 --- a/examples/tests/test_01_provider_flask.py +++ b/examples/tests/test_01_provider_flask.py @@ -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 @@ -133,6 +134,7 @@ def test_against_broker(broker: URL, verifier: Verifier) -> None: For an example of the consumer's contract, see the consumer's tests. """ + time.sleep(2) # give the broker time to start code, _ = verifier.verify_with_broker( broker_url=str(broker), # Despite the auth being set in the broker URL, we still need to pass diff --git a/examples/tests/test_04_v3_message_consumer.py b/examples/tests/test_04_v3_message_consumer.py new file mode 100644 index 000000000..b8a1cc578 --- /dev/null +++ b/examples/tests/test_04_v3_message_consumer.py @@ -0,0 +1,145 @@ +""" +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 logging +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Generator, + Union, +) +from unittest.mock import MagicMock + +import pytest + +from examples.src.message import Handler +from pact.v3.pact import MessagePact as Pact + +if TYPE_CHECKING: + from collections.abc import Callable + + from pact.v3.interaction import AsyncMessagePactResult + +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_content` 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_content(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() -> tuple[Handler, Callable[Dict[str, Any], Union[str, None]]]: + handler = Handler() + handler.fs = MagicMock() + + # need a function to accept the params + # the pact will send in during verify + # and call the actual function under test + def pact_handler(msg: dict[str, Any], context: dict[str, Any]) -> Union[str, None]: + log.info("Processing message: ", + extra={ "processed_message": msg, "context": context}) + return handler.process(msg) + + log.info("Handler created") + return handler, pact_handler + + +def test_async_message_handler_write( + pact: Pact, + handler: tuple[Handler, Callable[Dict[str, Any], Union[str, None]]], +) -> None: + """ + Create a pact between the message handler and the message provider. + """ + actual_handler, pact_handler = handler + actual_handler.fs.write.return_value = None + async_message = { + "action": "WRITE", + "path": "my_file.txt", + "contents": "Hello, world!", + } + processed_message: AsyncMessagePactResult = ( + pact.upon_receiving("a write request", "Async") + .given("a request to write test.txt") + .with_content(async_message) + .verify(pact_handler) + ) + actual_handler.fs.write.assert_called_once_with( # type: ignore[attr-defined] + async_message["path"], + async_message["contents"], + ) + assert processed_message is not None + assert processed_message.response is None + + +def test_async_message_handler_read( + pact: Pact, + handler: tuple[Handler, Callable[Dict[str, Any], Union[str, None]]], +) -> None: + """ + Create a pact between the message handler and the message provider. + """ + actual_handler, pact_handler = handler + async_message = { + "action": "READ", + "path": "my_file.txt", + "contents": "Hello, world!", + } + actual_handler.fs.read.return_value = async_message["contents"] + processed_message: AsyncMessagePactResult = ( + pact.upon_receiving("a read request", "Async") + .given("a request to read test.txt") + .with_content(async_message) + .verify(pact_handler) + ) + actual_handler.fs.read.assert_called_once_with( # type: ignore[attr-defined] + "my_file.txt", + ) + assert processed_message is not None + assert processed_message.response == async_message["contents"] diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index f97389cdb..290c66848 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -181,17 +181,14 @@ class MatchingRuleResult: ... class Message: def __init__(self, ptr: cffi.FFI.CData) -> None: """ - Initialise a new Message + Initialise a new Message. Args: ptr: CFFI data structure. """ if ffi.typeof(ptr).cname != "struct Message *": - msg = ( - "ptr must be a struct Message, got" - f" {ffi.typeof(ptr).cname}" - ) + msg = "ptr must be a struct Message, got" f" {ffi.typeof(ptr).cname}" raise TypeError(msg) self._ptr = ptr @@ -209,17 +206,30 @@ def __repr__(self) -> str: @property def description(self) -> str: - return ffi.string(lib.pactffi_message_get_description(self._ptr)).decode('utf-8') + """ + Message description. + """ + return ffi.string(lib.pactffi_message_get_description(self._ptr)).decode( + "utf-8" + ) @property def contents(self) -> str: + """ + Message contents. + """ _contents = lib.pactffi_message_get_contents(self._ptr) if _contents != ffi.NULL: - return ffi.string(lib.pactffi_message_get_contents(self._ptr)).decode('utf-8') + return ffi.string(lib.pactffi_message_get_contents(self._ptr)).decode( + "utf-8" + ) return None @property def metadata(self) -> list[MessageMetadataPair]: + """ + Message metadata. + """ return [{m.key: m.value} for m in message_get_metadata_iter(self)] @@ -259,7 +269,7 @@ def __repr__(self) -> str: class MessageMetadataIterator: """ - Iterator over a Message's metadata + Iterator over a Message's metadata. """ def __init__(self, ptr: cffi.FFI.CData) -> None: @@ -297,6 +307,9 @@ def __del__(self) -> None: message_metadata_iter_delete(self) def __iter__(self) -> MessageMetadataIterator: + """ + Return the iterator itself. + """ return self def __next__(self) -> MessageMetadataPair: @@ -309,7 +322,6 @@ def __next__(self) -> MessageMetadataPair: return MessageMetadataPair(msg) - class MessageMetadataPair: def __init__(self, ptr: cffi.FFI.CData) -> None: """ @@ -341,11 +353,17 @@ def __repr__(self) -> str: @property def key(self) -> str: - return ffi.string(self._ptr.key).decode('utf-8') + """ + Metadata key. + """ + return ffi.string(self._ptr.key).decode("utf-8") @property def value(self) -> str: - return ffi.string(self._ptr.value).decode('utf-8') + """ + Metadata value. + """ + return ffi.string(self._ptr.value).decode("utf-8") class MessagePact: ... @@ -389,7 +407,6 @@ def __repr__(self) -> str: return f"MessagePactHandle({self._ref!r})" - class MessagePactMessageIterator: ... @@ -509,8 +526,7 @@ def __init__(self, ptr: cffi.FFI.CData) -> None: """ if ffi.typeof(ptr).cname != "struct PactInteraction *": msg = ( - "ptr must be a struct PactInteraction, got" - f" {ffi.typeof(ptr).cname}" + "ptr must be a struct PactInteraction, got" f" {ffi.typeof(ptr).cname}" ) raise TypeError(msg) self._ptr = ptr @@ -528,7 +544,6 @@ def __repr__(self) -> str: return f"PactInteraction({self._ptr!r})" - class PactInteractionIterator: """ Iterator over a Pact's interactions. @@ -572,6 +587,9 @@ def __del__(self) -> None: pact_interaction_iter_delete(self) def __iter__(self) -> PactInteractionIterator: + """ + Return the iterator itself. + """ return self def __next__(self) -> PactInteraction: @@ -740,20 +758,16 @@ class Provider: ... class ProviderState: - def __init__(self, ptr: cffi.FFI.CData) -> None: """ - Initialise a new ProviderState + Initialise a new ProviderState. Args: ptr: CFFI data structure. """ if ffi.typeof(ptr).cname != "struct ProviderState *": - msg = ( - "ptr must be a struct ProviderState, got" - f" {ffi.typeof(ptr).cname}" - ) + msg = "ptr must be a struct ProviderState, got" f" {ffi.typeof(ptr).cname}" raise TypeError(msg) self._ptr = ptr @@ -771,21 +785,29 @@ def __repr__(self) -> str: @property def name(self) -> str: - return ffi.string(lib.pactffi_provider_state_get_name(self._ptr)).decode('utf-8') + """ + Provider State name. + """ + return ffi.string(lib.pactffi_provider_state_get_name(self._ptr)).decode( + "utf-8" + ) @property def parameters(self) -> list[ProviderStateParamPair]: + """ + Provider State parameters. + """ return [{p.key: p.value} for p in provider_state_get_param_iter(self)] class ProviderStateIterator: """ - Iterator over an interactions ProviderStates + Iterator over an interactions ProviderStates. """ def __init__(self, ptr: cffi.FFI.CData) -> None: """ - Initialise a new Provider State Iterator + Initialise a new Provider State Iterator. Args: ptr: @@ -832,12 +854,12 @@ def __next__(self) -> ProviderStateParamPair: class ProviderStateParamIterator: """ - Iterator over a Provider States Parameters + Iterator over a Provider States Parameters. """ def __init__(self, ptr: cffi.FFI.CData) -> None: """ - Initialise a new Provider State Param Iterator + Initialise a new Provider State Param Iterator. Args: ptr: @@ -875,7 +897,7 @@ def __iter__(self) -> ProviderStateParamIterator: """ return self - def __next__(self) -> ProviderStateParam: + def __next__(self) -> ProviderStateParamPair: """ Get the next message from the iterator. """ @@ -913,11 +935,17 @@ def __repr__(self) -> str: @property def key(self) -> str: - return ffi.string(self._ptr.key).decode('utf-8') + """ + Provider State Param key. + """ + return ffi.string(self._ptr.key).decode("utf-8") @property def value(self) -> str: - return ffi.string(self._ptr.value).decode('utf-8') + """ + Provider State Param value. + """ + return ffi.string(self._ptr.value).decode("utf-8") class SynchronousHttp: ... @@ -3354,7 +3382,7 @@ def pact_interaction_as_message(interaction: PactInteraction) -> Message: # Errors On any error, this function will return a NULL pointer. """ - raise NotImplementedError + return Message(lib.pactffi_pact_interaction_as_message(interaction._handle)) def pact_interaction_as_asynchronous_message( @@ -3597,8 +3625,8 @@ def message_new_from_json( If the JSON string is invalid or not UTF-8 encoded, returns a NULL. """ return lib.pactffi_message_new_from_json( - ffi.new('unsigned int *', index)[0], - ffi.new('char[]', json_str.encode("utf-8")), + ffi.new("unsigned int *", index)[0], + ffi.new("char[]", json_str.encode("utf-8")), spec_version, ) @@ -3829,7 +3857,9 @@ def message_get_provider_state_iter(message: Message) -> ProviderStateIterator: Returns NULL if an error occurs. """ - return ProviderStateIterator(lib.pactffi_message_get_provider_state_iter(message._ptr)) + return ProviderStateIterator( + lib.pactffi_message_get_provider_state_iter(message._ptr) + ) def provider_state_iter_next(iter: ProviderStateIterator) -> ProviderState: @@ -3889,7 +3919,9 @@ def message_find_metadata(message: Message, key: str) -> str: This function may fail if the provided `key` string contains invalid UTF-8, or if the Rust string contains embedded null ('\0') bytes. """ - return ffi.string(lib.pactffi_message_find_metadata(message._ptr, key.encode('utf-8'))) + return ffi.string( + lib.pactffi_message_find_metadata(message._ptr, key.encode("utf-8")) + ) def message_insert_metadata(message: Message, key: str, value: str) -> int: @@ -4321,7 +4353,9 @@ def provider_state_get_param_iter( This function may fail if any of the Rust strings contain embedded null ('\0') bytes. """ - return ProviderStateParamIterator(lib.pactffi_provider_state_get_param_iter(provider_state._ptr)) + return ProviderStateParamIterator( + lib.pactffi_provider_state_get_param_iter(provider_state._ptr) + ) def provider_state_param_iter_next( @@ -6492,7 +6526,7 @@ def message_expects_to_receive(message: MessageHandle, description: str) -> None def message_given(message: MessageHandle, description: str) -> None: """ - Adds a provider state to the Message + Adds a provider state to the Message. [Rust `pactffi_given`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_given) @@ -6596,10 +6630,7 @@ def message_with_contents( for text bodies (JSON, XML, etc.). """ lib.pactffi_message_with_contents( - message_handle._ref, - content_type.encode("utf-8"), - body.encode("utf-8"), - size + message_handle._ref, content_type.encode("utf-8"), body.encode("utf-8"), size ) @@ -6725,20 +6756,15 @@ def write_message_pact_file( return if ret == 1: msg = ( - f"The function panicked while writing the Pact for {mock_server_handle} in" - f" {directory}." + f"The function panicked while writing the Message Pact in {directory}." ) elif ret == 2: # noqa: PLR2004 msg = ( - f"The Pact file for {mock_server_handle} could not be written in" - f" {directory}." + "The message pact for the given handle was not found." ) - 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}." + "An unknown error occurred while writing the Message Pact." ) raise RuntimeError(msg) raise NotImplementedError diff --git a/src/pact/v3/interaction/__init__.py b/src/pact/v3/interaction/__init__.py index 661fed172..2ead27a28 100644 --- a/src/pact/v3/interaction/__init__.py +++ b/src/pact/v3/interaction/__init__.py @@ -71,11 +71,12 @@ """ from pact.v3.interaction._async_message_interaction import AsyncMessageInteraction -from pact.v3.interaction._base import Interaction +from pact.v3.interaction._base import AsyncMessagePactResult, Interaction from pact.v3.interaction._http_interaction import HttpInteraction from pact.v3.interaction._sync_message_interaction import SyncMessageInteraction __all__ = [ + "AsyncMessagePactResult", "Interaction", "HttpInteraction", "AsyncMessageInteraction", diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index 9b1ca0125..1b338bdb9 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -3,11 +3,12 @@ """ from __future__ import annotations -from typing import Callable + import json +from typing import Any, Callable, Self import pact.v3.ffi -from pact.v3.interaction._base import Interaction +from pact.v3.interaction._base import AsyncMessagePactResult, Interaction class AsyncMessageInteraction(Interaction): @@ -24,11 +25,7 @@ class AsyncMessageInteraction(Interaction): This class is not yet fully implemented and is not yet usable. """ - def __init__( - self, - pact_handle: pact.v3.ffi.PactHandle, - description: str - ) -> None: + def __init__(self, pact_handle: pact.v3.ffi.PactHandle, description: str) -> None: """ Initialise a new Asynchronous Message Interaction. @@ -39,13 +36,14 @@ def __init__( Args: pact_handle: - Handle for the Pact. + The Pact instance this interaction belongs to. description: Description of the interaction. This must be unique within the Pact. """ super().__init__(description) + self._pact_handle = pact_handle self.__handle = pact.v3.ffi.new_message_interaction(pact_handle, description) @property @@ -62,7 +60,9 @@ def _handle(self) -> pact.v3.ffi.InteractionHandle: def _interaction_part(self) -> pact.v3.ffi.InteractionPart: return pact.v3.ffi.InteractionPart.REQUEST - def with_content(self, content: dict[str, Any], content_type='application/json') -> Self: + def with_content( + self, content: dict[str, Any], content_type: str = "application/json" + ) -> Self: """ Set the content of the message. @@ -70,14 +70,14 @@ def with_content(self, content: dict[str, Any], content_type='application/json') content: The content of the message, as a dictionary. + content_type: + The content type of the message. Defaults to `"application/json"`. + Returns: The current instance of the interaction. """ pact.v3.ffi.message_with_contents( - self._handle, - content_type, - json.dumps(content), - 0 + self._handle, content_type, json.dumps(content), 0 ) return self @@ -98,3 +98,27 @@ def with_metadata(self, metadata: dict[str, Any]) -> Self: ] return self + def reify(self) -> str: + return pact.v3.ffi.message_reify(self._handle) + + def verify( + self, handler: Callable[[dict[str, Any], dict[str, Any]], Any] + ) -> AsyncMessagePactResult: + reified_msg = self.reify() + if not reified_msg: + return [] + msg = json.loads(reified_msg) + processed_message = AsyncMessagePactResult( + description=msg.get("description"), + contents=msg.get("contents"), + metadata=msg.get("metadata"), + response=None, + ) + async_message = context = {} + if msg.get("contents") is not None: + async_message = msg["contents"] + if msg.get("metadata") is not None: + context = msg["metadata"] + response: any = handler(async_message, context) + processed_message.response = response + return processed_message diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index 1b375d29a..4dd0b934d 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -471,3 +471,41 @@ def with_matching_rules( rules, ) return self + + +class AsyncMessagePactResult: + """ + Result of the message verification. + """ + + def __init__( + self, + description: str, + contents: str | None, + metadata: dict[str, str] | None, + response: any, + ) -> None: + """ + Initialise a new MessagePactResult. + + Args: + description: + Description of the interaction. + + contents: + Contents of the message. + + metadata: + Metadata of the message. + + response: + Response from the provider. + """ + self.description = description + self.contents = contents + self.metadata = metadata + self.response = response + + def __str__(self) -> str: + return f"MessagePactResult(description={self.description})" + diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index 451a64143..7b4be2765 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -65,7 +65,13 @@ import json import logging from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, Set, overload +from typing import ( + TYPE_CHECKING, + Any, + Literal, + Set, + overload, +) from yarl import URL @@ -279,9 +285,9 @@ def upon_receiving( """ if interaction == "HTTP": return HttpInteraction(self._handle, description) - elif interaction == "Async": + if interaction == "Async": return AsyncMessageInteraction(self._handle, description) - elif interaction == "Sync": + if interaction == "Sync": return SyncMessageInteraction(self._handle, description) msg = f"Invalid interaction type: {interaction}" @@ -385,6 +391,23 @@ def write_file( class Pact(BasePact): + """ + 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.v3.pact.Pact.upon_receiving] method, which + returns a sub-class of [`Interaction`][pact.v3.interaction.Interaction]. + """ def serve( # noqa: PLR0913 self, @@ -446,45 +469,40 @@ def serve( # noqa: PLR0913 ) - class MessagePact(BasePact): - def get_provider_states(self): + """ + A Message Pact between a consumer and a provider. + + This class defines a Pact between a consumer and a provider for an + asynchronous message pattern. 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.v3.pact.Pact.upon_receiving] method, which + returns a sub-class of [`Interaction`][pact.v3.interaction.Interaction]. + """ + + def get_provider_states(self) -> list[dict[str, Any]]: """ Get the provider states for the interaction. Returns: A list of provider states for the interaction. """ - provider_state_data = [] - for message in pact.v3.ffi.pact_handle_get_message_iter(self._handle): - for provider_state in pact.v3.ffi.message_get_provider_state_iter(message): - provider_state_data.append({ - 'name': provider_state.name, - 'params': provider_state.parameters - }) - return provider_state_data - - def verify(self, handler) -> list[dict[str, Any]]: - processed_messages = [] - _mutable_pact = pact.v3.ffi.pact_handle_to_pointer(self._handle) - for interaction in pact.v3.ffi.pact_model_interaction_iterator(_mutable_pact): - msg_iter = pact.v3.ffi.pact_handle_get_message_iter(self._handle) - for msg in msg_iter: - processed_messages.append({ - "description": msg.description, - "contents": msg.contents, - "metadata": msg.metadata, - }) - try: - async_message = context = {} - if msg.contents is not None: - async_message = json.loads(msg.contents) - if msg.metadata is not None: - context = msg.metadata - handler(async_message, context) - except Exception as e: - raise e - return processed_messages + return [ + { + "name": provider_state.name, + "params": provider_state.parameters, + } + for message in pact.v3.ffi.pact_handle_get_message_iter(self._handle) + for provider_state in pact.v3.ffi.message_get_provider_state_iter(message) + ] class MismatchesError(Exception): @@ -798,4 +816,3 @@ def write_file( str(directory), overwrite=overwrite, ) - diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py index 08faa4ca6..d166f6c43 100644 --- a/tests/v3/compatibility_suite/test_v3_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -3,7 +3,6 @@ import ast import json -import logging import re from pathlib import Path from typing import Any, Generator, NamedTuple @@ -21,7 +20,6 @@ from pact.v3.pact import MessagePact as Pact from tests.v3.compatibility_suite.util import FIXTURES_ROOT, parse_markdown_table -logger = logging.getLogger(__name__) class PactInteraction(NamedTuple): """Holder class for Pact and Interaction.""" @@ -248,7 +246,7 @@ def fail( received_payload["data"] = ReceivedPayload(async_message, context) raise TestFailedError try: - pact_interaction.pact.verify(fail) + pact_interaction.interaction.verify(fail) except Exception as e: # noqa: BLE001 return PactResult(received_payload["data"], None, e) @@ -268,7 +266,7 @@ def handler( context: dict[Any, Any], ) -> None: received_payload["data"] = ReceivedPayload(async_message, context) - pact_interaction.pact.verify(handler) + pact_interaction.interaction.verify(handler) (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) pact_interaction.pact.write_file(temp_dir / "pacts") with ( @@ -481,10 +479,7 @@ def the_received_message_content_type_will_be( content_type: str, ) -> None: """The received message content type will be "application/json".""" - assert any( - context.get("contentType") == content_type - for context in pact_result.received_payload.context - ) + assert pact_result.received_payload.context.get("contentType") == content_type @then( parsers.re( @@ -499,10 +494,10 @@ def the_received_message_metadata_will_contain_replaced_with( ) -> None: """The received message metadata will contain "ID" replaced with an "integer".""" found = False - for metadata in pact_result.received_payload.context: - if metadata.get(key): - assert compare_type(expected_type, metadata[key]) - found = True + metadata = pact_result.received_payload.context + if metadata.get(key): + assert compare_type(expected_type, metadata[key]) + found = True assert found @@ -523,15 +518,10 @@ def the_received_message_metadata_will_contain( value = value.replace("JSON: ", "") value = value.replace('\\"', '"') value = json.loads(value) - for metadata in pact_result.received_payload.context: - if metadata.get(key): - if isinstance(value, dict): - assert json.loads(metadata[key]) == value - elif NUM_RE.match(metadata[key]): - assert ast.literal_eval(metadata[key]) == value - else: - assert metadata[key] == value - found = True + metadata = pact_result.received_payload.context + if metadata.get(key): + assert metadata[key] == value + found = True assert found From 49f06ee07095dc74704ce38919ae2f64b67bb86a Mon Sep 17 00:00:00 2001 From: valkolovos Date: Tue, 28 May 2024 15:18:10 -0600 Subject: [PATCH 08/61] setting harder version on jsonpath_ng --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 047d571a0..fd5cfe39d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ devel-test = [ "pytest-bdd ~=7.0", "pytest-cov ~=5.0", "testcontainers ~=3.0", - "jsonpath-ng", + "jsonpath-ng ~=1.6", ] devel = ["pact-python[devel-types,devel-docs,devel-test]", "ruff==0.4.2"] From 2e460f72fd8fa8a8a1c6f5ad73a9fbf47461efc2 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Fri, 31 May 2024 12:50:53 -0600 Subject: [PATCH 09/61] cleaning up ffi.py --- src/pact/v3/ffi.py | 380 +++++---------------------------------------- 1 file changed, 40 insertions(+), 340 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index 290c66848..ac26d897c 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -204,207 +204,23 @@ def __repr__(self) -> str: """ return f"Message({self._ptr!r})" - @property - def description(self) -> str: - """ - Message description. - """ - return ffi.string(lib.pactffi_message_get_description(self._ptr)).decode( - "utf-8" - ) - - @property - def contents(self) -> str: - """ - Message contents. - """ - _contents = lib.pactffi_message_get_contents(self._ptr) - if _contents != ffi.NULL: - return ffi.string(lib.pactffi_message_get_contents(self._ptr)).decode( - "utf-8" - ) - return None - - @property - def metadata(self) -> list[MessageMetadataPair]: - """ - Message metadata. - """ - return [{m.key: m.value} for m in message_get_metadata_iter(self)] - class MessageContents: ... -class MessageHandle: - """ - Handle to a Message. - - [Rust - `MessageHandle`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/mock_server/handles/struct.MessageHandle.html) - """ - - def __init__(self, ref: int) -> None: - """ - Initialise a new Message Handle. +class MessageHandle: ... - Args: - ref: - Reference to the Message Handle. - """ - self._ref: int = ref - def __str__(self) -> str: - """ - String representation of the Message Handle. - """ - return f"MessageHandle({self._ref})" +class MessageMetadataIterator: ... - def __repr__(self) -> str: - """ - String representation of the Message Handle. - """ - return f"MessageHandle({self._ref!r})" - -class MessageMetadataIterator: - """ - Iterator over a Message's metadata. - """ - - def __init__(self, ptr: cffi.FFI.CData) -> None: - """ - Initialise a new Message Metadata Iterator. - - Args: - ptr: - CFFI data structure. - """ - 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 Message Metadata Iterator. - """ - message_metadata_iter_delete(self) - - def __iter__(self) -> MessageMetadataIterator: - """ - Return the iterator itself. - """ - return self - - def __next__(self) -> MessageMetadataPair: - """ - Get the next interaction from the iterator. - """ - msg = message_metadata_iter_next(self) - if msg == ffi.NULL: - raise StopIteration - return MessageMetadataPair(msg) - - -class MessageMetadataPair: - def __init__(self, ptr: cffi.FFI.CData) -> None: - """ - Initialise a new MessageMetadataPair. - - Args: - ptr: - CFFI data structure. - """ - if ffi.typeof(ptr).cname != "struct MessageMetadataPair *": - msg = ( - "ptr must be a struct MessageMetadataPair, got" - f" {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})" - - @property - def key(self) -> str: - """ - Metadata key. - """ - return ffi.string(self._ptr.key).decode("utf-8") - - @property - def value(self) -> str: - """ - Metadata value. - """ - return ffi.string(self._ptr.value).decode("utf-8") +class MessageMetadataPair: ... class MessagePact: ... -class MessagePactHandle: - """ - Handle to a Pact. - - [Rust - `PactHandle`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/mock_server/handles/struct.PactHandle.html) - """ - - def __init__(self, ref: int) -> None: - """ - Initialise a new Message Pact Handle. - - Args: - ref: - Rust library reference to the Pact Handle. - """ - self._ref: int = ref - - def __del__(self) -> None: - """ - Destructor for the Message Pact Handle. - """ - cleanup_plugins(self) - free_message_pact_handle(self) - - def __str__(self) -> str: - """ - String representation of the Message Pact Handle. - """ - return f"MessagePactHandle({self._ref})" - - def __repr__(self) -> str: - """ - String representation of the Message Pact Handle. - """ - return f"MessagePactHandle({self._ref!r})" +class MessagePactHandle: ... class MessagePactMessageIterator: ... @@ -515,33 +331,7 @@ def port(self) -> int: return self._ref -class PactInteraction: - def __init__(self, ptr: cffi.FFI.CData) -> None: - """ - Initialise a new Pact Interaction. - - Args: - ptr: - CFFI data structure. - """ - if ffi.typeof(ptr).cname != "struct PactInteraction *": - msg = ( - "ptr must be a struct PactInteraction, got" f" {ffi.typeof(ptr).cname}" - ) - raise TypeError(msg) - self._ptr = ptr - - def __str__(self) -> str: - """ - Nice string representation. - """ - return "PactInteraction" - - def __repr__(self) -> str: - """ - Debugging representation. - """ - return f"PactInteraction({self._ptr!r})" +class PactInteraction: ... class PactInteractionIterator: @@ -586,12 +376,6 @@ def __del__(self) -> None: """ pact_interaction_iter_delete(self) - def __iter__(self) -> PactInteractionIterator: - """ - Return the iterator itself. - """ - return self - def __next__(self) -> PactInteraction: """ Get the next interaction from the iterator. @@ -788,9 +572,7 @@ def name(self) -> str: """ Provider State name. """ - return ffi.string(lib.pactffi_provider_state_get_name(self._ptr)).decode( - "utf-8" - ) + return provider_state_get_name(self) @property def parameters(self) -> list[ProviderStateParamPair]: @@ -1792,7 +1574,7 @@ def pact_model_interaction_iterator(pact: Pact) -> PactInteractionIterator: # Errors On any error, this function will return a NULL pointer. """ - return PactInteractionIterator(lib.pactffi_pact_model_interaction_iterator(pact)) + raise NotImplementedError def pact_spec_version(pact: Pact) -> PactSpecification: @@ -3382,7 +3164,7 @@ def pact_interaction_as_message(interaction: PactInteraction) -> Message: # Errors On any error, this function will return a NULL pointer. """ - return Message(lib.pactffi_pact_interaction_as_message(interaction._handle)) + raise NotImplementedError def pact_interaction_as_asynchronous_message( @@ -3513,6 +3295,7 @@ def pact_interaction_iter_next(iter: PactInteractionIterator) -> PactInteraction ptr = lib.pactffi_pact_interaction_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration + raise NotImplementedError return PactInteraction(ptr) @@ -3624,11 +3407,7 @@ def message_new_from_json( If the JSON string is invalid or not UTF-8 encoded, returns a NULL. """ - return lib.pactffi_message_new_from_json( - ffi.new("unsigned int *", index)[0], - ffi.new("char[]", json_str.encode("utf-8")), - spec_version, - ) + raise NotImplementedError def message_new_from_body(body: str, content_type: str) -> Message: @@ -3919,9 +3698,7 @@ def message_find_metadata(message: Message, key: str) -> str: This function may fail if the provided `key` string contains invalid UTF-8, or if the Rust string contains embedded null ('\0') bytes. """ - return ffi.string( - lib.pactffi_message_find_metadata(message._ptr, key.encode("utf-8")) - ) + raise NotImplementedError def message_insert_metadata(message: Message, key: str, value: str) -> int: @@ -3964,10 +3741,7 @@ def message_metadata_iter_next(iter: MessageMetadataIterator) -> MessageMetadata If no further data is present, returns NULL. """ - message_metadata = lib.pactffi_message_metadata_iter_next(iter._ptr) - if message_metadata == ffi.NULL: - raise StopIteration - return message_metadata + raise NotImplementedError def message_get_metadata_iter(message: Message) -> MessageMetadataIterator: @@ -3992,7 +3766,7 @@ def message_get_metadata_iter(message: Message) -> MessageMetadataIterator: This function may fail if any of the Rust strings contain embedded null ('\0') bytes. """ - return MessageMetadataIterator(lib.pactffi_message_get_metadata_iter(message._ptr)) + raise NotImplementedError def message_metadata_iter_delete(iter: MessageMetadataIterator) -> None: @@ -4002,7 +3776,7 @@ def message_metadata_iter_delete(iter: MessageMetadataIterator) -> None: [Rust `pactffi_message_metadata_iter_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_metadata_iter_delete) """ - lib.pactffi_message_metadata_iter_delete(iter._ptr) + raise NotImplementedError def message_metadata_pair_delete(pair: MessageMetadataPair) -> None: @@ -4326,7 +4100,9 @@ def provider_state_get_name(provider_state: ProviderState) -> str: If the provider_state param is NULL, this returns NULL. """ - raise NotImplementedError + return ffi.string(lib.pactffi_provider_state_get_name(provider_state._ptr)).decode( + "utf-8" + ) def provider_state_get_param_iter( @@ -4403,7 +4179,7 @@ def provider_state_param_iter_delete(iter: ProviderStateParamIterator) -> None: [Rust `pactffi_provider_state_param_iter_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_provider_state_param_iter_delete) """ - lib.pactffi_provider_state_param_iter_delete(iter._ptr) + raise NotImplementedError def provider_state_param_pair_delete(pair: ProviderStateParamPair) -> None: @@ -5147,10 +4923,15 @@ def write_pact_file( return if ret == 1: msg = ( - f"The Pact file for {mock_server_handle} could not be written in" + 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 = ( @@ -5294,7 +5075,7 @@ def pact_handle_to_pointer(pact: PactHandle) -> Pact: The returned Pact model must be freed with the `pactffi_pact_model_delete` function when no longer needed. """ - return lib.pactffi_pact_handle_to_pointer(pact._ref) + raise NotImplementedError def new_interaction(pact: PactHandle, description: str) -> InteractionHandle: @@ -6483,12 +6264,7 @@ def new_message_pact(consumer_name: str, provider_name: str) -> MessagePactHandl Returns a new `MessagePactHandle`. The handle will need to be freed with the `pactffi_free_message_pact_handle` function to release its resources. """ - return MessagePactHandle( - lib.pactffi_new_message_pact( - consumer_name.encode("utf-8"), - provider_name.encode("utf-8"), - ), - ) + raise NotImplementedError def new_message(pact: MessagePactHandle, description: str) -> MessageHandle: @@ -6503,12 +6279,7 @@ def new_message(pact: MessagePactHandle, description: str) -> MessageHandle: Returns a new `MessageHandle`. """ - return MessageHandle( - lib.pactffi_new_message( - pact._ref, - description.encode("utf-8"), - ), - ) + raise NotImplementedError def message_expects_to_receive(message: MessageHandle, description: str) -> None: @@ -6526,24 +6297,15 @@ def message_expects_to_receive(message: MessageHandle, description: str) -> None def message_given(message: MessageHandle, description: str) -> None: """ - Adds a provider state to the Message. + Adds a provider state to the Interaction. [Rust - `pactffi_given`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_given) - - Args: - message: - Handle to the Message. + `pactffi_message_given`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_given) - description: - The provider state description. It needs to be unique. - - Raises: - RuntimeError: If the provider state could not be specified. + * `description` - The provider state description. It needs to be unique for + each message """ - # message_given does not return anything, - # so we can't check for errors - lib.pactffi_message_given(message._ref, description.encode("utf-8")) + raise NotImplementedError def message_given_with_param( @@ -6553,57 +6315,22 @@ def message_given_with_param( value: str, ) -> None: """ - Adds a parameter key and value to a provider state to the Message. - - 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. + Adds a provider state to the Message with a parameter key and value. [Rust - `pactffi_given_with_param`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_given_with_param) - - Args: - message: - Handle to the Message. - - description: - The provider state description. - - name: - Parameter name. - - value: - Parameter value as JSON. - - Raises: - RuntimeError: If the interaction state could not be updated. - - # Errors - - Returns EXIT_FAILURE (1) if the interaction or Pact can't be modified (i.e. - the mock server for it has already started). - - Returns 2 and sets the error message (which can be retrieved with - `pactffi_get_error_message`) if the parameter values con't be parsed as - JSON. - - Returns 3 if any of the C strings are not valid. + `pactffi_message_given_with_param`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_given_with_param) + * `description` - The provider state description. It needs to be unique. + * `name` - Parameter name. + * `value` - Parameter value. """ - # message_given_with_param does not return anything, - # so we can't check for errors - lib.pactffi_message_given_with_param( - message._ref, - description.encode("utf-8"), - name.encode("utf-8"), - value.encode("utf-8"), - ) + raise NotImplementedError def message_with_contents( message_handle: MessageHandle, content_type: str, - body: str, + body: List[int], size: int, ) -> None: """ @@ -6747,26 +6474,6 @@ def write_message_pact_file( | 1 | The pact file was not able to be written | | 2 | The message pact for the given handle was not found | """ - ret: int = lib.pactffi_write_message_pact_file( - pact._ref, - str(directory).encode("utf-8"), - overwrite, - ) - if ret == 0: - return - if ret == 1: - msg = ( - f"The function panicked while writing the Message Pact in {directory}." - ) - elif ret == 2: # noqa: PLR2004 - msg = ( - "The message pact for the given handle was not found." - ) - else: - msg = ( - "An unknown error occurred while writing the Message Pact." - ) - raise RuntimeError(msg) raise NotImplementedError @@ -6873,14 +6580,7 @@ def free_message_pact_handle(pact: MessagePactHandle) -> int: that it was previously deleted. """ - ret: int = lib.pactffi_free_message_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) + raise NotImplementedError def verify(args: str) -> int: From 1645640d566127e080ae032e4f87d7f4cd912813 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Wed, 5 Jun 2024 15:12:41 -0600 Subject: [PATCH 10/61] PR changes --- .../test_01_v3_message_consumer.py} | 0 pyproject.toml | 1 - src/pact/v3/ffi.py | 2 +- src/pact/v3/pact.py | 16 +++++----- .../test_v3_message_consumer.py | 31 +++++++------------ 5 files changed, 20 insertions(+), 30 deletions(-) rename examples/tests/{test_04_v3_message_consumer.py => v3/test_01_v3_message_consumer.py} (100%) diff --git a/examples/tests/test_04_v3_message_consumer.py b/examples/tests/v3/test_01_v3_message_consumer.py similarity index 100% rename from examples/tests/test_04_v3_message_consumer.py rename to examples/tests/v3/test_01_v3_message_consumer.py diff --git a/pyproject.toml b/pyproject.toml index fd5cfe39d..a8ef954c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,6 @@ devel-test = [ "pytest-bdd ~=7.0", "pytest-cov ~=5.0", "testcontainers ~=3.0", - "jsonpath-ng ~=1.6", ] devel = ["pact-python[devel-types,devel-docs,devel-test]", "ruff==0.4.2"] diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index ac26d897c..260ef2f37 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -575,7 +575,7 @@ def name(self) -> str: return provider_state_get_name(self) @property - def parameters(self) -> list[ProviderStateParamPair]: + def parameters(self) -> dict[str, str]: """ Provider State parameters. """ diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index 7b4be2765..08ef22f53 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -468,6 +468,8 @@ def serve( # noqa: PLR0913 verbose=verbose, ) +HttpPact = Pact + class MessagePact(BasePact): """ @@ -495,14 +497,12 @@ def get_provider_states(self) -> list[dict[str, Any]]: Returns: A list of provider states for the interaction. """ - return [ - { - "name": provider_state.name, - "params": provider_state.parameters, - } - for message in pact.v3.ffi.pact_handle_get_message_iter(self._handle) - for provider_state in pact.v3.ffi.message_get_provider_state_iter(message) - ] + for message in pact.v3.ffi.pact_handle_get_message_iter(self._handle): + for provider_state in pact.v3.ffi.message_get_provider_state_iter(message): + yield { + "name": provider_state.name, + "params": provider_state.parameters, + } class MismatchesError(Exception): diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py index d166f6c43..be7c20626 100644 --- a/tests/v3/compatibility_suite/test_v3_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import Any, Generator, NamedTuple -from jsonpath_ng import parse from pytest_bdd import ( given, parsers, @@ -56,8 +55,6 @@ def __init__(self) -> None: """Initialize the TestFailedError.""" super().__init__("Test failed") -NUM_RE = re.compile(r"^-?[.0-9]+$") - @scenario( "definition/features/V3/message_consumer.feature", "Supports arbitrary message metadata" @@ -421,10 +418,9 @@ def the_message_contents_will_have_been_replaced_with( expected_type: str, ) -> None: """The message contents for "$.one" will have been replaced with an "integer".""" - path = parse(replace_token) - values = [match.value for match in path.find(pact_result.received_payload.message)] - for v in values: - assert compare_type(expected_type, v) + elem_key = replace_token.split(".")[1] + value = pact_result.received_payload.message.get(elem_key) + assert compare_type(expected_type, value) @then( @@ -578,19 +574,14 @@ def _build_body_generator( body_json: dict[str, Any] ) -> None: for k, v in generator_json["body"].items(): - path = parse(k) - current_values = [match.value for match in path.find(body_json)] - matches = path.find(body_json) - for i, _ in enumerate(matches): - generator_type = v["type"] - del v["type"] - replacement_value = { - "value": current_values[i], - "pact:matcher:type": "notEmpty", - "pact:generator:type": generator_type, - } - replacement_value.update(v) - matches[i].full_path.update(body_json, replacement_value) + elem_name = k.split(".")[1] + body_elem = body_json.get(elem_name) + replace_value = { + "pact:generator:type": v["type"], + "pact:matcher:type": "notEmpty", + "value": body_elem, + } + body_json.update({elem_name: replace_value}) def _build_metadata_generator( generator_json: dict[str, Any], From deec3151e6837f341c1e37400e9d830738a4de65 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Wed, 5 Jun 2024 15:23:56 -0600 Subject: [PATCH 11/61] importing Self from typing_extensions for backwards compatibility --- src/pact/v3/interaction/_async_message_interaction.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index 1b338bdb9..f57ccd3fe 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -5,7 +5,9 @@ from __future__ import annotations import json -from typing import Any, Callable, Self +from typing import Any, Callable + +from typing_extensions import Self import pact.v3.ffi from pact.v3.interaction._base import AsyncMessagePactResult, Interaction From 9db002194289b5caf90c4dfa12eb8255297efd8a Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 10:11:40 +1000 Subject: [PATCH 12/61] chore(examples): ensure docker compose is started Ensure that the Postgres image is fully up and running before launching the broker. This is unlikely to be an issue, but there's hardly any impact to adding this. Signed-off-by: JP-Ellis --- examples/docker-compose.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml index 2405a49a9..1b39fa089 100644 --- a/examples/docker-compose.yml +++ b/examples/docker-compose.yml @@ -15,7 +15,8 @@ services: broker: image: pactfoundation/pact-broker:latest-multi depends_on: - - postgres + postgres: + condition: service_healthy ports: - "9292:9292" restart: always @@ -41,3 +42,4 @@ services: interval: 1s timeout: 2s retries: 5 + start_period: 30s From 9aef6b493a48aed4cc5709f80265b7249a10cc50 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 10:19:45 +1000 Subject: [PATCH 13/61] chore(examples): wait for servers to start In some circumstances, the test will try and connect to the FastAPI/Flask server before the server has had a chance to be fully initialised. As these are very lightweight servers, a simple second wait after the process is spawned should suffice. Signed-off-by: JP-Ellis --- examples/tests/test_01_provider_fastapi.py | 2 +- examples/tests/test_01_provider_flask.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/tests/test_01_provider_fastapi.py b/examples/tests/test_01_provider_fastapi.py index ddcaeb9b2..a95b5b5f8 100644 --- a/examples/tests/test_01_provider_fastapi.py +++ b/examples/tests/test_01_provider_fastapi.py @@ -94,6 +94,7 @@ def verifier() -> Generator[Verifier, Any, None]: provider_base_url=str(PROVIDER_URL), ) proc.start() + time.sleep(2) yield verifier proc.kill() @@ -146,7 +147,6 @@ def test_against_broker(broker: URL, verifier: Verifier) -> None: For an example of the consumer's contract, see the consumer's tests. """ - time.sleep(2) # give the broker time to start code, _ = verifier.verify_with_broker( broker_url=str(broker), # Despite the auth being set in the broker URL, we still need to pass diff --git a/examples/tests/test_01_provider_flask.py b/examples/tests/test_01_provider_flask.py index e26feddcc..b7082dabb 100644 --- a/examples/tests/test_01_provider_flask.py +++ b/examples/tests/test_01_provider_flask.py @@ -82,6 +82,7 @@ def verifier() -> Generator[Verifier, Any, None]: provider_base_url=str(PROVIDER_URL), ) proc.start() + time.sleep(2) yield verifier proc.kill() @@ -134,7 +135,6 @@ def test_against_broker(broker: URL, verifier: Verifier) -> None: For an example of the consumer's contract, see the consumer's tests. """ - time.sleep(2) # give the broker time to start code, _ = verifier.verify_with_broker( broker_url=str(broker), # Despite the auth being set in the broker URL, we still need to pass From dbcfe80069f50fce9827001518daf95ff2750cd7 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 10:23:58 +1000 Subject: [PATCH 14/61] docs(examples): explain the purpose for fs class As the examples are meant to be pedagogical, the docstrings have been expanded to explain _why_ there is a Filesystem class which only raises `NotImplementedError`. Signed-off-by: JP-Ellis --- examples/src/message.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/examples/src/message.py b/examples/src/message.py index cab137ec0..815719903 100644 --- a/examples/src/message.py +++ b/examples/src/message.py @@ -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.""" From 713e59d56e4205b6dbc0f12deae316edf18cd8bb Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 10:26:29 +1000 Subject: [PATCH 15/61] chore(examples): remove redundant v3 in filename Signed-off-by: JP-Ellis --- ...test_01_v3_message_consumer.py => test_01_message_consumer.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/tests/v3/{test_01_v3_message_consumer.py => test_01_message_consumer.py} (100%) diff --git a/examples/tests/v3/test_01_v3_message_consumer.py b/examples/tests/v3/test_01_message_consumer.py similarity index 100% rename from examples/tests/v3/test_01_v3_message_consumer.py rename to examples/tests/v3/test_01_message_consumer.py From 4070ecdc4004539c828da9c981a0077abdc199d6 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 10:32:32 +1000 Subject: [PATCH 16/61] fix(examples): typing annotations There were a few minor issues with the typing annotations: - `Callable` takes two arguments: 1. A list of types for the arguments of the function 2. A single type for the function's return - Prefer the use of the (more succinct) `|` instead of `Union[...]` Signed-off-by: JP-Ellis --- examples/tests/v3/test_01_message_consumer.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/examples/tests/v3/test_01_message_consumer.py b/examples/tests/v3/test_01_message_consumer.py index b8a1cc578..935186f6e 100644 --- a/examples/tests/v3/test_01_message_consumer.py +++ b/examples/tests/v3/test_01_message_consumer.py @@ -14,13 +14,12 @@ Any, Dict, Generator, - Union, ) from unittest.mock import MagicMock import pytest - from examples.src.message import Handler + from pact.v3.pact import MessagePact as Pact if TYPE_CHECKING: @@ -74,7 +73,7 @@ def pact() -> Generator[Pact, None, None]: @pytest.fixture() -def handler() -> tuple[Handler, Callable[Dict[str, Any], Union[str, None]]]: +def handler() -> tuple[Handler, Callable[[Dict[str, Any], Dict[str, Any]], str | None]]: handler = Handler() handler.fs = MagicMock() @@ -92,7 +91,13 @@ def pact_handler(msg: dict[str, Any], context: dict[str, Any]) -> Union[str, Non def test_async_message_handler_write( pact: Pact, - handler: tuple[Handler, Callable[Dict[str, Any], Union[str, None]]], + handler: tuple[ + Handler, + Callable[ + [Dict[str, Any], Dict[str, Any]], + str | None, + ], + ], ) -> None: """ Create a pact between the message handler and the message provider. @@ -120,7 +125,13 @@ def test_async_message_handler_write( def test_async_message_handler_read( pact: Pact, - handler: tuple[Handler, Callable[Dict[str, Any], Union[str, None]]], + handler: tuple[ + Handler, + Callable[ + [Dict[str, Any], Dict[str, Any]], + str | None, + ], + ], ) -> None: """ Create a pact between the message handler and the message provider. From cef8f21a32d6a8b3c4499491dae783df4abded1d Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 10:47:39 +1000 Subject: [PATCH 17/61] chore(examples): silence deprecation warnings Signed-off-by: JP-Ellis --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index a8ef954c2..7c90e1875 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] From 0f76c5f066c9541781039c39ae96b7d2a85b71f4 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 16:24:08 +1000 Subject: [PATCH 18/61] refactor(v3): rename AsyncMessagePactResult to AsyncInteractionResult As the result is from a single asynchronous message interaction, it seemed like a more appropriate name. As part of the refactor, the declaration of the class has been moved and some minor refactoring took place too. Signed-off-by: JP-Ellis --- examples/tests/v3/test_01_message_consumer.py | 13 +++--- src/pact/v3/interaction/__init__.py | 9 ++-- .../interaction/_async_message_interaction.py | 42 ++++++++++--------- src/pact/v3/interaction/_base.py | 38 ----------------- 4 files changed, 35 insertions(+), 67 deletions(-) diff --git a/examples/tests/v3/test_01_message_consumer.py b/examples/tests/v3/test_01_message_consumer.py index 935186f6e..0401df5d3 100644 --- a/examples/tests/v3/test_01_message_consumer.py +++ b/examples/tests/v3/test_01_message_consumer.py @@ -25,7 +25,6 @@ if TYPE_CHECKING: from collections.abc import Callable - from pact.v3.interaction import AsyncMessagePactResult log = logging.getLogger(__name__) @@ -80,9 +79,11 @@ def handler() -> tuple[Handler, Callable[[Dict[str, Any], Dict[str, Any]], str | # need a function to accept the params # the pact will send in during verify # and call the actual function under test - def pact_handler(msg: dict[str, Any], context: dict[str, Any]) -> Union[str, None]: - log.info("Processing message: ", - extra={ "processed_message": msg, "context": context}) + def pact_handler(msg: Dict[str, Any], context: Dict[str, Any]) -> str | None: + log.info( + "Processing message: ", + extra={"processed_message": msg, "context": context}, + ) return handler.process(msg) log.info("Handler created") @@ -109,7 +110,7 @@ def test_async_message_handler_write( "path": "my_file.txt", "contents": "Hello, world!", } - processed_message: AsyncMessagePactResult = ( + processed_message = ( pact.upon_receiving("a write request", "Async") .given("a request to write test.txt") .with_content(async_message) @@ -143,7 +144,7 @@ def test_async_message_handler_read( "contents": "Hello, world!", } actual_handler.fs.read.return_value = async_message["contents"] - processed_message: AsyncMessagePactResult = ( + processed_message = ( pact.upon_receiving("a read request", "Async") .given("a request to read test.txt") .with_content(async_message) diff --git a/src/pact/v3/interaction/__init__.py b/src/pact/v3/interaction/__init__.py index 2ead27a28..465c22dbc 100644 --- a/src/pact/v3/interaction/__init__.py +++ b/src/pact/v3/interaction/__init__.py @@ -70,15 +70,18 @@ in the interaction. """ -from pact.v3.interaction._async_message_interaction import AsyncMessageInteraction -from pact.v3.interaction._base import AsyncMessagePactResult, Interaction +from pact.v3.interaction._async_message_interaction import ( + AsyncMessageInteraction, + AsyncMessageInteractionResult, +) +from pact.v3.interaction._base import Interaction from pact.v3.interaction._http_interaction import HttpInteraction from pact.v3.interaction._sync_message_interaction import SyncMessageInteraction __all__ = [ - "AsyncMessagePactResult", "Interaction", "HttpInteraction", "AsyncMessageInteraction", + "AsyncMessageInteractionResult", "SyncMessageInteraction", ] diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index f57ccd3fe..0eb4c8b37 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -5,12 +5,13 @@ from __future__ import annotations import json -from typing import Any, Callable +from dataclasses import dataclass +from typing import Any, Callable, Dict from typing_extensions import Self import pact.v3.ffi -from pact.v3.interaction._base import AsyncMessagePactResult, Interaction +from pact.v3.interaction._base import Interaction class AsyncMessageInteraction(Interaction): @@ -104,23 +105,24 @@ def reify(self) -> str: return pact.v3.ffi.message_reify(self._handle) def verify( - self, handler: Callable[[dict[str, Any], dict[str, Any]], Any] - ) -> AsyncMessagePactResult: + self, handler: Callable[[Any, dict[str, Any]], Any] + ) -> AsyncMessageInteractionResult | None: reified_msg = self.reify() if not reified_msg: - return [] - msg = json.loads(reified_msg) - processed_message = AsyncMessagePactResult( - description=msg.get("description"), - contents=msg.get("contents"), - metadata=msg.get("metadata"), - response=None, - ) - async_message = context = {} - if msg.get("contents") is not None: - async_message = msg["contents"] - if msg.get("metadata") is not None: - context = msg["metadata"] - response: any = handler(async_message, context) - processed_message.response = response - return processed_message + return None + result = AsyncMessageInteractionResult(**json.loads(reified_msg)) + response = handler(result.contents or {}, result.metadata or {}) + result.response = response + return result + + +@dataclass +class AsyncMessageInteractionResult: + """ + Result of the message verification. + """ + + description: str + contents: str | None = None + metadata: Dict[str, str] | None = None + response: Any | None = None diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index 4dd0b934d..1b375d29a 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -471,41 +471,3 @@ def with_matching_rules( rules, ) return self - - -class AsyncMessagePactResult: - """ - Result of the message verification. - """ - - def __init__( - self, - description: str, - contents: str | None, - metadata: dict[str, str] | None, - response: any, - ) -> None: - """ - Initialise a new MessagePactResult. - - Args: - description: - Description of the interaction. - - contents: - Contents of the message. - - metadata: - Metadata of the message. - - response: - Response from the provider. - """ - self.description = description - self.contents = contents - self.metadata = metadata - self.response = response - - def __str__(self) -> str: - return f"MessagePactResult(description={self.description})" - From 57d1a0782d4a8b072b83557b3e2a1ea10c82c35b Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 16:29:25 +1000 Subject: [PATCH 19/61] refactor(v3): merge Pact classes From Pact V4, it is possible for a single Pact to mix different interaction types; that is, to combine sync/async messages, and HTTP interactions. As such, I think it is best to keep a single `Pact` class. Signed-off-by: JP-Ellis --- src/pact/v3/pact.py | 165 +++++++++++++++++--------------------------- 1 file changed, 62 insertions(+), 103 deletions(-) diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index 08ef22f53..96f8e0360 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -68,6 +68,7 @@ from typing import ( TYPE_CHECKING, Any, + Generator, Literal, Set, overload, @@ -93,7 +94,7 @@ logger = logging.getLogger(__name__) -class BasePact: +class Pact: """ A Pact between a consumer and a provider. @@ -293,6 +294,65 @@ def upon_receiving( 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: + A [`PactServer`][pact.v3.pact.PactServer] instance. + """ + return PactServer( + self._handle, + addr, + port, + transport, + transport_config, + raises=raises, + verbose=verbose, + ) + def messages(self) -> pact.v3.ffi.PactMessageIterator: """ Iterate over the messages in the Pact. @@ -389,108 +449,7 @@ def write_file( overwrite=overwrite, ) - -class Pact(BasePact): - """ - 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.v3.pact.Pact.upon_receiving] method, which - returns a sub-class of [`Interaction`][pact.v3.interaction.Interaction]. - """ - - 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: - A [`PactServer`][pact.v3.pact.PactServer] instance. - """ - return PactServer( - self._handle, - addr, - port, - transport, - transport_config, - raises=raises, - verbose=verbose, - ) - -HttpPact = Pact - - -class MessagePact(BasePact): - """ - A Message Pact between a consumer and a provider. - - This class defines a Pact between a consumer and a provider for an - asynchronous message pattern. 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.v3.pact.Pact.upon_receiving] method, which - returns a sub-class of [`Interaction`][pact.v3.interaction.Interaction]. - """ - - def get_provider_states(self) -> list[dict[str, Any]]: + def get_provider_states(self) -> Generator[dict[str, Any], Any, None]: """ Get the provider states for the interaction. From 7bfa7b8e8ed1189c1c44069ff7f448408da03cf3 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 16:31:31 +1000 Subject: [PATCH 20/61] chore(v3): remove _pact_handle as it is never used Signed-off-by: JP-Ellis --- src/pact/v3/interaction/_async_message_interaction.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index 0eb4c8b37..0b6d85753 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -46,7 +46,6 @@ def __init__(self, pact_handle: pact.v3.ffi.PactHandle, description: str) -> Non Pact. """ super().__init__(description) - self._pact_handle = pact_handle self.__handle = pact.v3.ffi.new_message_interaction(pact_handle, description) @property From 07d413b77a27d66a7dd0bf3cd4d262655311c96f Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 16:31:52 +1000 Subject: [PATCH 21/61] docs(v3): add some clarity about interaction parts Especially in light of the addition of asynchronous messages which only have one part. Signed-off-by: JP-Ellis --- .../v3/interaction/_async_message_interaction.py | 10 ++++++++++ src/pact/v3/interaction/_base.py | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index 0b6d85753..83e88815f 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -60,6 +60,16 @@ def _handle(self) -> pact.v3.ffi.InteractionHandle: @property def _interaction_part(self) -> pact.v3.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.v3.ffi.InteractionPart.REQUEST], as there the + consumer of the message does not send any responses. + """ return pact.v3.ffi.InteractionPart.REQUEST def with_content( diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index 1b375d29a..ec95d3780 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -37,6 +37,19 @@ class Interaction(abc.ABC): - [`HttpInteraction`][pact.v3.interaction.HttpInteraction] - [`AsyncMessageInteraction`][pact.v3.interaction.AsyncMessageInteraction] - [`SyncMessageInteraction`][pact.v3.interaction.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: From a972986b327290cb55251a2f0a22329cb96c3e8b Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 16:32:34 +1000 Subject: [PATCH 22/61] docs(ffi): remove unnecessary safety message The safety concern is handled by the use of `OwnedString`. Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index 260ef2f37..76cb4cfca 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -6431,13 +6431,6 @@ def message_reify(message_handle: MessageHandle) -> OwnedString: Reification is the process of stripping away any matchers, and returning the original contents. - - # Safety - - The returned string needs to be deallocated with the `free_string` function. - 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. """ return OwnedString(lib.pactffi_message_reify(message_handle._ref)) From 941f123624084316809c376458cc3c154d3ae0db Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 17:01:31 +1000 Subject: [PATCH 23/61] chore: use existing method Signed-off-by: JP-Ellis --- src/pact/v3/pact.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index 96f8e0360..40970215f 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -456,7 +456,7 @@ def get_provider_states(self) -> Generator[dict[str, Any], Any, None]: Returns: A list of provider states for the interaction. """ - for message in pact.v3.ffi.pact_handle_get_message_iter(self._handle): + for message in self.messages(): for provider_state in pact.v3.ffi.message_get_provider_state_iter(message): yield { "name": provider_state.name, From 72584556403367b0af29367c702d47a051eeccee Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 6 Jun 2024 17:01:59 +1000 Subject: [PATCH 24/61] chore(v3): minor refactor of with_contents Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 31 ++++++++++--------- .../interaction/_async_message_interaction.py | 20 +++++++++--- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index 76cb4cfca..03be690ed 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -6330,8 +6330,7 @@ def message_given_with_param( def message_with_contents( message_handle: MessageHandle, content_type: str, - body: List[int], - size: int, + body: str | bytes, ) -> None: """ Adds the contents of the Message. @@ -6342,22 +6341,24 @@ def message_with_contents( Accepts JSON, binary and other payload types. Binary data will be base64 encoded when serialised. - Note: For text bodies (plain text, JSON or XML), you can pass in a C string - (NULL terminated) and the size of the body is not required (it will be - ignored). For binary bodies, you need to specify the number of bytes in the - body. + Args: + message_handle: + Handle to the Message. + + body: + The body contents. For JSON payloads, matching rules can be embedded + in the body. See [JSON matching + rule](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.19/rust/pact_ffi/IntegrationJson.md). - * `content_type` - The content type of the body. Defaults to `text/plain`, - supports JSON structures with matchers and binary data. - * `body` - The body contents as bytes. For text payloads (JSON, XML, etc.), - a C string can be used and matching rules can be embedded in the body. - * `content_type` - Expected content type (e.g. application/json, - application/octet-stream) - * `size` - number of bytes in the message body to read. This is not required - for text bodies (JSON, XML, etc.). + content_type: + The content type of the body. """ + body = body.encode("utf-8") if isinstance(body, str) else body lib.pactffi_message_with_contents( - message_handle._ref, content_type.encode("utf-8"), body.encode("utf-8"), size + message_handle._ref, + content_type.encode("utf-8"), + body, + len(body), ) diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index 83e88815f..d195104eb 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -73,23 +73,35 @@ def _interaction_part(self) -> pact.v3.ffi.InteractionPart: return pact.v3.ffi.InteractionPart.REQUEST def with_content( - self, content: dict[str, Any], content_type: str = "application/json" + self, + content: str | bytes, + content_type: str = "text/plain", ) -> Self: """ Set the content of the message. Args: content: - The content of the message, as a dictionary. + The message content, as a string or bytes. + + This can be any content that the consumer expects to receive, + whether it be plain text, JSON, XML, or some binary format. + + Binary payloads are encoded as base64 strings when serialised. + + JSON payloads may embeded [JSON matching + rules](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.19/rust/pact_ffi/IntegrationJson.md). content_type: - The content type of the message. Defaults to `"application/json"`. + The content type of the message. Returns: The current instance of the interaction. """ pact.v3.ffi.message_with_contents( - self._handle, content_type, json.dumps(content), 0 + self._handle, + content_type, + content, ) return self From dd4af8ac1c22f3d37c4ab6707e795f5aa7540bd3 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 7 Jun 2024 09:10:01 +1000 Subject: [PATCH 25/61] chore(v3): remove public reify I doubt this will be useful to end-users. Signed-off-by: JP-Ellis --- .../interaction/_async_message_interaction.py | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index d195104eb..a88977eea 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -105,30 +105,54 @@ def with_content( ) return self - def with_metadata(self, metadata: dict[str, Any]) -> Self: + def with_metadata( + self, + __metadata: dict[str, str] | None = None, + /, + **kwargs: str, + ) -> Self: """ Set the metadata of the message. + 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 therefore): + + ```python + interaction.with_metadata({"key": "value", "key two": "value two"}) + interaction.with_metadata(foo="bar", baz="qux") + ``` + + !!! note + + The implementation treats the key `__metadata` as a special case. + Should there ever be a need to set metadata with the key + `__metadata`, it is must be passed through as a dictionary: + + ```python + interaction.with_metadata({"__metadata": "value"}) + ``` + Args: metadata: - The metadata of the message, as a dictionary. + Dictionary of metadata keys and associated values. + + **kwargs: + Additional metadata key-value pairs. Returns: The current instance of the interaction. """ - [ - pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) - for k, v in metadata.items() - ] + for k, v in (__metadata or {}).items(): + pact.v3.ffi.message_with_metadata(self._handle, k, v) + for k, v in kwargs.items(): + pact.v3.ffi.message_with_metadata(self._handle, k, v) return self - def reify(self) -> str: - return pact.v3.ffi.message_reify(self._handle) - def verify( self, handler: Callable[[Any, dict[str, Any]], Any] ) -> AsyncMessageInteractionResult | None: - reified_msg = self.reify() + reified_msg = pact.v3.ffi.message_reify(self._handle) if not reified_msg: return None result = AsyncMessageInteractionResult(**json.loads(reified_msg)) From 8f0815d5aa112547d163fa5d260fb5b647eb07fb Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 7 Jun 2024 09:10:47 +1000 Subject: [PATCH 26/61] chore(v3): use with_metadata_v2 also remove the deprecated `message_with_metadata` function from the FFI Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 13 ------------- .../v3/interaction/_async_message_interaction.py | 4 ++-- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index 03be690ed..e3fe32c6a 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -6362,19 +6362,6 @@ def message_with_contents( ) -def message_with_metadata(message_handle: MessageHandle, key: str, value: str) -> None: - """ - Adds expected metadata to the Message. - - [Rust - `pactffi_message_with_metadata`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_with_metadata) - - * `key` - metadata key - * `value` - metadata value. - """ - raise NotImplementedError - - def message_with_metadata_v2( message_handle: MessageHandle, key: str, diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index a88977eea..f494c7033 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -144,9 +144,9 @@ def with_metadata( The current instance of the interaction. """ for k, v in (__metadata or {}).items(): - pact.v3.ffi.message_with_metadata(self._handle, k, v) + pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) for k, v in kwargs.items(): - pact.v3.ffi.message_with_metadata(self._handle, k, v) + pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) return self def verify( From f79a20d36fc6941c5248dee35120b419e00563a7 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 7 Jun 2024 09:11:28 +1000 Subject: [PATCH 27/61] chore(v3): publicly export Pact and Verifier Signed-off-by: JP-Ellis --- src/pact/v3/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pact/v3/__init__.py b/src/pact/v3/__init__.py index 91329f2dc..15a207faa 100644 --- a/src/pact/v3/__init__.py +++ b/src/pact/v3/__init__.py @@ -70,8 +70,8 @@ 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 " @@ -79,3 +79,5 @@ stacklevel=2, category=ImportWarning, ) + +__all__ = ["Pact", "Verifier"] From 9326a900b7dff45a9b9b3fb521519bddbe34602f Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 7 Jun 2024 09:16:39 +1000 Subject: [PATCH 28/61] chore(ffi): minor type/style fixes Signed-off-by: JP-Ellis --- examples/tests/v3/test_01_message_consumer.py | 11 +- src/pact/v3/ffi.py | 27 ++- .../interaction/_async_message_interaction.py | 8 +- .../test_v3_message_consumer.py | 228 ++++++++++-------- .../test_v4_message_consumer.py | 49 ++-- 5 files changed, 174 insertions(+), 149 deletions(-) diff --git a/examples/tests/v3/test_01_message_consumer.py b/examples/tests/v3/test_01_message_consumer.py index 0401df5d3..bc7efd390 100644 --- a/examples/tests/v3/test_01_message_consumer.py +++ b/examples/tests/v3/test_01_message_consumer.py @@ -7,6 +7,7 @@ from __future__ import annotations +import json import logging from pathlib import Path from typing import ( @@ -20,7 +21,7 @@ import pytest from examples.src.message import Handler -from pact.v3.pact import MessagePact as Pact +from pact.v3.pact import Pact if TYPE_CHECKING: from collections.abc import Callable @@ -104,7 +105,7 @@ def test_async_message_handler_write( Create a pact between the message handler and the message provider. """ actual_handler, pact_handler = handler - actual_handler.fs.write.return_value = None + actual_handler.fs.write.return_value = None # type: ignore[attr-defined] async_message = { "action": "WRITE", "path": "my_file.txt", @@ -113,7 +114,7 @@ def test_async_message_handler_write( processed_message = ( pact.upon_receiving("a write request", "Async") .given("a request to write test.txt") - .with_content(async_message) + .with_content(json.dumps(async_message)) .verify(pact_handler) ) actual_handler.fs.write.assert_called_once_with( # type: ignore[attr-defined] @@ -143,11 +144,11 @@ def test_async_message_handler_read( "path": "my_file.txt", "contents": "Hello, world!", } - actual_handler.fs.read.return_value = async_message["contents"] + actual_handler.fs.read.return_value = async_message["contents"] # type: ignore[attr-defined] processed_message = ( pact.upon_receiving("a read request", "Async") .given("a request to read test.txt") - .with_content(async_message) + .with_content(json.dumps(async_message)) .verify(pact_handler) ) actual_handler.fs.read.assert_called_once_with( # type: ignore[attr-defined] diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index e3fe32c6a..6835d7797 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -579,7 +579,7 @@ def parameters(self) -> dict[str, str]: """ Provider State parameters. """ - return [{p.key: p.value} for p in provider_state_get_param_iter(self)] + return {p.key: p.value for p in provider_state_get_param_iter(self)} class ProviderStateIterator: @@ -627,7 +627,7 @@ def __iter__(self) -> ProviderStateIterator: """ return self - def __next__(self) -> ProviderStateParamPair: + def __next__(self) -> ProviderState: """ Get the next message from the iterator. """ @@ -720,14 +720,20 @@ def key(self) -> str: """ Provider State Param key. """ - return ffi.string(self._ptr.key).decode("utf-8") + 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. """ - return ffi.string(self._ptr.value).decode("utf-8") + s = ffi.string(self._ptr.value) # type: ignore[attr-defined] + if isinstance(s, bytes): + s = s.decode("utf-8") + return s class SynchronousHttp: ... @@ -4100,9 +4106,10 @@ def provider_state_get_name(provider_state: ProviderState) -> str: If the provider_state param is NULL, this returns NULL. """ - return ffi.string(lib.pactffi_provider_state_get_name(provider_state._ptr)).decode( - "utf-8" - ) + s = ffi.string(lib.pactffi_provider_state_get_name(provider_state._ptr)) + if isinstance(s, bytes): + s = s.decode("utf-8") + return s def provider_state_get_param_iter( @@ -6355,7 +6362,7 @@ def message_with_contents( """ body = body.encode("utf-8") if isinstance(body, str) else body lib.pactffi_message_with_contents( - message_handle._ref, + message_handle._ref, # type: ignore[attr-defined] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 content_type.encode("utf-8"), body, len(body), @@ -6404,7 +6411,7 @@ def message_with_metadata_v2( See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.19/rust/pact_ffi/IntegrationJson.md). """ lib.pactffi_message_with_metadata_v2( - message_handle._ref, + message_handle._ref, # type: ignore[attr-defined] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 key.encode("utf-8"), value.encode("utf-8"), ) @@ -6420,7 +6427,7 @@ def message_reify(message_handle: MessageHandle) -> OwnedString: Reification is the process of stripping away any matchers, and returning the original contents. """ - return OwnedString(lib.pactffi_message_reify(message_handle._ref)) + return OwnedString(lib.pactffi_message_reify(message_handle._ref)) # type: ignore[attr-defined] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 def write_message_pact_file( diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index f494c7033..a5c971527 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -99,7 +99,7 @@ def with_content( The current instance of the interaction. """ pact.v3.ffi.message_with_contents( - self._handle, + self._handle, # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 content_type, content, ) @@ -144,15 +144,15 @@ def with_metadata( The current instance of the interaction. """ for k, v in (__metadata or {}).items(): - pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) + pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 for k, v in kwargs.items(): - pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) + pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 return self def verify( self, handler: Callable[[Any, dict[str, Any]], Any] ) -> AsyncMessageInteractionResult | None: - reified_msg = pact.v3.ffi.message_reify(self._handle) + reified_msg = pact.v3.ffi.message_reify(self._handle) # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 if not reified_msg: return None result = AsyncMessageInteractionResult(**json.loads(reified_msg)) diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py index be7c20626..3a5a227e3 100644 --- a/tests/v3/compatibility_suite/test_v3_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -1,11 +1,12 @@ """V3 Message consumer feature tests.""" + from __future__ import annotations import ast import json import re from pathlib import Path -from typing import Any, Generator, NamedTuple +from typing import Any, Generator, NamedTuple, Tuple from pytest_bdd import ( given, @@ -15,49 +16,59 @@ when, ) -from pact.v3.pact import AsyncMessageInteraction -from pact.v3.pact import MessagePact as Pact +from pact.v3.pact import AsyncMessageInteraction, Pact from tests.v3.compatibility_suite.util import FIXTURES_ROOT, parse_markdown_table class PactInteraction(NamedTuple): """Holder class for Pact and Interaction.""" + pact: Pact interaction: AsyncMessageInteraction class PactResult(NamedTuple): """Holder class for Pact Result objects.""" - received_payload: dict[str, Any] + + received_payload: ReceivedPayload pact_data: dict[str, Any] | None - error: str | None + error: Exception | None + class ReceivedPayload(NamedTuple): """Holder class for Message Received Payload.""" - message: any - context: any + + message: Any + context: Any + class UnknownTypeError(Exception): """Unknown type error.""" + def __init__(self, expected_type: str) -> None: """Initialize the UnknownTypeError.""" super().__init__(f"Unknown type: {expected_type}") + class UnknownGeneratorCategoryError(Exception): """Unknown type error.""" + def __init__(self, generator_category: str) -> None: """Initialize the UnknownGeneratorCategoryError.""" super().__init__(f"Unknown generator category: {generator_category}") + class TestFailedError(Exception): """Test failed error.""" + def __init__(self) -> None: """Initialize the TestFailedError.""" super().__init__("Test failed") + @scenario( "definition/features/V3/message_consumer.feature", - "Supports arbitrary message metadata" + "Supports arbitrary message metadata", ) def test_supports_arbitrary_message_metadata() -> None: """Supports arbitrary message metadata.""" @@ -65,7 +76,7 @@ def test_supports_arbitrary_message_metadata() -> None: @scenario( "definition/features/V3/message_consumer.feature", - "Supports data for provider states" + "Supports data for provider states", ) def test_supports_data_for_provider_states() -> None: """Supports data for provider states.""" @@ -73,7 +84,7 @@ def test_supports_data_for_provider_states() -> None: @scenario( "definition/features/V3/message_consumer.feature", - "Supports specifying provider states" + "Supports specifying provider states", ) def test_supports_specifying_provider_states() -> None: """Supports specifying provider states.""" @@ -81,7 +92,7 @@ def test_supports_specifying_provider_states() -> None: @scenario( "definition/features/V3/message_consumer.feature", - "Supports the use of generators with message metadata" + "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.""" @@ -89,7 +100,7 @@ def test_supports_the_use_of_generators_with_message_metadata() -> None: @scenario( "definition/features/V3/message_consumer.feature", - "Supports the use of generators with the message body" + "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.""" @@ -97,7 +108,7 @@ def test_supports_the_use_of_generators_with_the_message_body() -> None: @scenario( "definition/features/V3/message_consumer.feature", - "When all messages are successfully processed" + "When all messages are successfully processed", ) def test_when_all_messages_are_successfully_processed() -> None: """When all messages are successfully processed.""" @@ -105,7 +116,7 @@ def test_when_all_messages_are_successfully_processed() -> None: @scenario( "definition/features/V3/message_consumer.feature", - "When not all messages are successfully processed" + "When not all messages are successfully processed", ) def test_when_not_all_messages_are_successfully_processed() -> None: """When not all messages are successfully processed.""" @@ -118,7 +129,7 @@ def test_when_not_all_messages_are_successfully_processed() -> None: @given( "a message integration is being defined for a consumer test", - target_fixture="pact_interaction" + target_fixture="pact_interaction", ) def a_message_integration_is_being_defined_for_a_consumer_test() -> ( Generator[tuple[Pact, AsyncMessageInteraction], Any, None] @@ -137,25 +148,21 @@ def _a_message_is_defined() -> None: @given( parsers.re( r'a provider state "(?P[^"]+)" for the message ' - r'is specified with the following data:\n(?P
.+)', + r"is specified with the following data:\n(?P
.+)", re.DOTALL, ), converters={"table": parse_markdown_table}, ) def a_provider_state_for_the_message_is_specified_with_the_following_data( - pact_interaction: PactInteraction, - state: str, - table: list[dict[str, Any]] + pact_interaction: PactInteraction, state: str, table: list[dict[str, Any]] ) -> None: """A provider state for the message is specified with the following data.""" for parameters in table: - state_params = { k: ast.literal_eval(v) for k, v in parameters.items() } + state_params = {k: ast.literal_eval(v) for k, v in parameters.items()} pact_interaction.interaction.given(state, parameters=state_params) -@given( - parsers.re(r'a provider state "(?P[^"]+)" for the message is specified') -) +@given(parsers.re(r'a provider state "(?P[^"]+)" for the message is specified')) def a_provider_state_for_the_message_is_specified( pact_interaction: PactInteraction, state: str, @@ -166,26 +173,23 @@ def a_provider_state_for_the_message_is_specified( @given( parsers.re( - "the message contains the following " - "metadata:\n(?P
.+)", re.DOTALL + "the message contains the following " "metadata:\n(?P
.+)", re.DOTALL ), converters={"table": parse_markdown_table}, ) def the_message_contains_the_following_metadata( - pact_interaction: PactInteraction, - table: list[dict[str, Any]] + pact_interaction: PactInteraction, table: list[dict[str, Any]] ) -> None: """The message contains the following metadata.""" for metadata in table: - if metadata.get("value","").startswith("JSON: "): + if metadata.get("value", "").startswith("JSON: "): metadata["value"] = metadata["value"].replace("JSON:", "") pact_interaction.interaction.with_metadata({metadata["key"]: metadata["value"]}) @given( parsers.re( - "the message is configured with the following:\n" - "(?P
.+)", re.DOTALL + "the message is configured with the following:\n" "(?P
.+)", re.DOTALL ), converters={"table": parse_markdown_table}, ) @@ -203,7 +207,7 @@ def the_message_is_configured_with_the_following( _build_metadata_generator(generator_json, metadata_json) else: raise UnknownGeneratorCategoryError(category) - pact_interaction.interaction.with_content(body_json) + pact_interaction.interaction.with_content(json.dumps(body_json)) for k, v in metadata_json.items(): v_str = v if isinstance(v, dict): @@ -215,11 +219,10 @@ def the_message_is_configured_with_the_following( parsers.re('the message payload contains the "(?P[^"]+)" JSON document') ) def the_message_payload_contains_the_basic_json_document( - pact_interaction: PactInteraction, - json_doc: str + pact_interaction: PactInteraction, json_doc: str ) -> None: """The message payload contains the "basic" JSON document.""" - pact_interaction.interaction.with_content(read_json(f"{json_doc}.json")) + pact_interaction.interaction.with_content(json.dumps(read_json(f"{json_doc}.json"))) ################################################################################ @@ -229,46 +232,43 @@ def the_message_payload_contains_the_basic_json_document( @when( 'the message is NOT successfully processed with a "Test failed" exception', - target_fixture="pact_result" + target_fixture="pact_result", ) def the_message_is_not_successfully_processed_with_an_exception( - pact_interaction: PactInteraction -) -> None: + pact_interaction: PactInteraction, +) -> PactResult: """The message is NOT successfully processed with a "Test failed" exception.""" # using a dict here because it's mutable - received_payload = {"data": None} - def fail( - async_message: str | dict[any: any], context: dict[any: any] - ) -> None: + received_payload: dict[str, ReceivedPayload] = {} + + def fail(async_message: str | dict[Any, Any], context: dict[Any, Any]) -> None: received_payload["data"] = ReceivedPayload(async_message, context) raise TestFailedError + try: pact_interaction.interaction.verify(fail) - except Exception as e: # noqa: BLE001 + return PactResult(received_payload["data"], None, None) + except Exception as e: # noqa: BLE001 return PactResult(received_payload["data"], None, e) -@when( - "the message is successfully processed", - target_fixture="pact_result" -) +@when("the message is successfully processed", target_fixture="pact_result") def the_message_is_successfully_processed( - pact_interaction: PactInteraction, - temp_dir: Path -) -> None: + pact_interaction: PactInteraction, temp_dir: Path +) -> Generator[PactResult, Any, None]: """The message is successfully processed.""" - received_payload = {"data": None} + received_payload: dict[str, ReceivedPayload] = {} + def handler( async_message: str | dict[Any, Any], context: dict[Any, Any], ) -> None: received_payload["data"] = ReceivedPayload(async_message, context) + pact_interaction.interaction.verify(handler) (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) pact_interaction.pact.write_file(temp_dir / "pacts") - with ( - temp_dir / "pacts" / "consumer-provider.json" - ).open() as file: + with (temp_dir / "pacts" / "consumer-provider.json").open() as file: yield PactResult(received_payload["data"], json.load(file), None) @@ -279,27 +279,21 @@ def handler( @then("a Pact file for the message interaction will NOT have been written") def a_pact_file_for_the_message_interaction_will_not_have_been_written( - temp_dir: Path + temp_dir: Path, ) -> None: """A Pact file for the message interaction will NOT have been written.""" - assert not Path( - temp_dir / "pacts" / "consumer-provider.json" - ).exists() + assert not Path(temp_dir / "pacts" / "consumer-provider.json").exists() @then("a Pact file for the message interaction will have been written") def a_pact_file_for_the_message_interaction_will_have_been_written( - temp_dir: Path + temp_dir: Path, ) -> None: """A Pact file for the message interaction will have been written.""" - assert Path( - temp_dir / "pacts" / "consumer-provider.json" - ).exists() + assert Path(temp_dir / "pacts" / "consumer-provider.json").exists() -@then( - parsers.re(r'the consumer test error will be "(?P[^"]+)"') -) +@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, @@ -309,18 +303,14 @@ def the_consumer_test_error_will_be_test_failed( @then("the consumer test will have failed") -def the_consumer_test_will_have_failed( - pact_result: PactResult -) -> None: +def the_consumer_test_will_have_failed(pact_result: PactResult) -> None: """The consumer test will have failed.""" assert type(pact_result.error) == TestFailedError assert pact_result.pact_data is None @then("the consumer test will have passed") -def the_consumer_test_will_have_passed( - pact_result: PactResult -) -> None: +def the_consumer_test_will_have_passed(pact_result: PactResult) -> None: """The consumer test will have passed.""" assert pact_result.error is None assert pact_result.pact_data is not None @@ -328,7 +318,7 @@ def the_consumer_test_will_have_passed( @then( parsers.re( - r'the first message in the Pact file will contain ' + r"the first message in the Pact file will contain " 'provider state "(?P[^"]+)"' ) ) @@ -337,15 +327,19 @@ def the_first_message_in_the_pact_file_will_contain_provider_state( state: str, ) -> None: """The first message in the Pact file will contain provider state.""" - assert state in [ - provider_state["name"] - for provider_state in pact_result.pact_data["messages"][0]["providerStates"] - ] + 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) + assert state in messages[0]["providerStates"] @then( parsers.re( - r'the first message in the pact file content type ' + r"the first message in the pact file content type " 'will be "(?P[^"]+)"' ) ) @@ -354,8 +348,14 @@ def the_first_message_in_the_pact_file_content_type_will_be( content_type: str, ) -> None: """The first message in the pact file content type will be "application/json".""" - messages: list[dict[str, Any]] = pact_result.pact_data["messages"] - assert messages[0]["metadata"]["contentType"] == content_type + 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( @@ -370,40 +370,57 @@ def the_first_message_in_the_pact_file_will_contain( state_count: int, ) -> None: """The first message in the pact file will contain 1 provider state.""" - assert len(pact_result.pact_data["messages"][0]["providerStates"]) == state_count + 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( - 'the first message in the pact file will contain ' + "the first message in the pact file will contain " 'the "(?P[^"]+)" document' ) ) def the_first_message_in_the_pact_file_will_contain_the_basic_json_document( - pact_result: PactResult, - json_doc: str + pact_result: PactResult, json_doc: str ) -> None: """The first message in the pact file will contain the "basic.json" document.""" - assert pact_result.pact_data["messages"][0]["contents"] == read_json(json_doc) + 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) + assert messages[0]["contents"] == read_json(json_doc) @then( parsers.re( - r'the first message in the pact file will contain ' + 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, + 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) - assert pact_result.pact_data["messages"][0]["metadata"][key] == 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( @@ -433,13 +450,17 @@ def the_pact_file_will_contain_message_interaction( interaction_count: int, ) -> None: """The pact file will contain 1 message interaction.""" - assert len(pact_result.pact_data["messages"]) == interaction_count + 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:\n(?P.+)', + r"will contain the following parameters:\n(?P.+)", re.DOTALL, ), converters={"parameters": parse_markdown_table}, @@ -458,7 +479,7 @@ def the_provider_state_for_the_message_will_contain_the_following_parameters( break # if we have provider_state_params, we found the expected provider state name assert provider_state_params is not None - found = { k: False for k in expected_params } + found = {k: False for k in expected_params} for k, v in expected_params.items(): for provider_state_param in provider_state_params: if provider_state_param.get(k): @@ -467,6 +488,7 @@ def the_provider_state_for_the_message_will_contain_the_following_parameters( break assert all(found.values()) + @then( parsers.re(r'the received message content type will be "(?P[^"]+)"') ) @@ -477,6 +499,7 @@ def the_received_message_content_type_will_be( """The received message content type will be "application/json".""" assert pact_result.received_payload.context.get("contentType") == content_type + @then( parsers.re( r'the received message metadata will contain "(?P[^"]+)" ' @@ -499,22 +522,23 @@ def the_received_message_metadata_will_contain_replaced_with( @then( parsers.re( - r'the received message metadata will contain ' + r"the received message metadata will contain " r'"(?P[^"]+)" == "(?P[^"\\]*(?:\\.[^"\\]*)*)"' ) ) def the_received_message_metadata_will_contain( pact_result: PactResult, key: str, - value: any, + value: Any, # noqa: ANN401 ) -> None: """The received message metadata will contain "Origin" == "Some Text".""" - found = False if value.startswith("JSON: "): value = value.replace("JSON: ", "") value = value.replace('\\"', '"') value = json.loads(value) metadata = pact_result.received_payload.context + + found = False if metadata.get(key): assert metadata[key] == value found = True @@ -523,13 +547,12 @@ def the_received_message_metadata_will_contain( @then( parsers.re( - r'the received message payload will contain ' + r"the received message payload will contain " 'the "(?P[^"]+)" JSON document' ) ) def the_received_message_payload_will_contain_the_basic_json_document( - pact_result: PactResult, - json_doc: str + pact_result: PactResult, json_doc: str ) -> None: """The received message payload will contain the "basic" JSON document.""" assert pact_result.received_payload.message == read_json(f"{json_doc}.json") @@ -542,6 +565,7 @@ def read_json(file: str) -> dict[str, Any]: def compare_type(expected_type: str, t: str | int | None) -> bool: if expected_type == "integer": + assert t is not None try: int(t) except ValueError: @@ -551,8 +575,8 @@ def compare_type(expected_type: str, t: str | int | None) -> bool: def _build_message_data( - table: list[dict[str, Any]] -) -> (dict[str, Any], dict[str, Any], dict[str, Any]): + table: list[dict[str, Any]], +) -> Tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: body_json = generator_json = metadata_json = {} for entry in table: for k, v in entry.items(): @@ -569,9 +593,9 @@ def _build_message_data( metadata_json = json.loads(v) return body_json, generator_json, metadata_json + def _build_body_generator( - generator_json: dict[str, Any], - body_json: dict[str, Any] + generator_json: dict[str, Any], body_json: dict[str, Any] ) -> None: for k, v in generator_json["body"].items(): elem_name = k.split(".")[1] @@ -583,14 +607,14 @@ def _build_body_generator( } body_json.update({elem_name: replace_value}) + def _build_metadata_generator( - generator_json: dict[str, Any], - metadata_json: dict[str, Any] + generator_json: dict[str, Any], metadata_json: dict[str, Any] ) -> None: for k in generator_json["metadata"]: metadata = metadata_json[k] if not isinstance(metadata, dict): - metadata = { "value": metadata } + metadata = {"value": metadata} metadata_json[k] = metadata generator_data = generator_json["metadata"][k] metadata.update({ diff --git a/tests/v3/compatibility_suite/test_v4_message_consumer.py b/tests/v3/compatibility_suite/test_v4_message_consumer.py index 978e3d3c3..d50a13717 100644 --- a/tests/v3/compatibility_suite/test_v4_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v4_message_consumer.py @@ -1,8 +1,9 @@ """Message consumer feature tests.""" + from __future__ import annotations import json -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, Generator, NamedTuple from pytest_bdd import ( given, @@ -12,8 +13,7 @@ when, ) -from pact.v3.pact import AsyncMessageInteraction -from pact.v3.pact import MessagePact as Pact +from pact.v3.pact import AsyncMessageInteraction, Pact from tests.v3.compatibility_suite.util import string_to_int if TYPE_CHECKING: @@ -22,29 +22,27 @@ class PactInteraction(NamedTuple): """Holder class for Pact and Interaction.""" + pact: Pact interaction: AsyncMessageInteraction @scenario( "definition/features/V4/message_consumer.feature", - "Sets the type for the interaction" + "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 adding comments" -) +@scenario("definition/features/V4/message_consumer.feature", "Supports adding comments") def test_supports_adding_comments() -> None: """Supports adding comments.""" @scenario( "definition/features/V4/message_consumer.feature", - "Supports specifying a key for the interaction" + "Supports specifying a key for the interaction", ) def test_supports_specifying_a_key_for_the_interaction() -> None: """Supports specifying a key for the interaction.""" @@ -52,7 +50,7 @@ def test_supports_specifying_a_key_for_the_interaction() -> None: @scenario( "definition/features/V4/message_consumer.feature", - "Supports specifying the interaction is pending" + "Supports specifying the interaction is pending", ) def test_supports_specifying_the_interaction_is_pending() -> None: """Supports specifying the interaction is pending.""" @@ -67,15 +65,14 @@ def test_supports_specifying_the_interaction_is_pending() -> None: parsers.re(r'a comment "(?P[^"]+)" is added to the message interaction') ) def a_comment_is_added_to_the_message_interaction( - pact_interaction: PactInteraction, - comment: str + pact_interaction: PactInteraction, 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') +@given( + parsers.re(r'a key of "(?P[^"]+)" is specified for the message interaction') ) def a_key_is_specified_for_the_http_interaction( pact_interaction: PactInteraction, @@ -87,9 +84,11 @@ def a_key_is_specified_for_the_http_interaction( @given( "a message interaction is being defined for a consumer test", - target_fixture="pact_interaction" + target_fixture="pact_interaction", ) -def a_message_interaction_is_being_defined_for_a_consumer_test() -> None: +def a_message_interaction_is_being_defined_for_a_consumer_test() -> ( + Generator[PactInteraction, Any, None] +): """A message integration is being defined for a consumer test.""" pact = Pact("consumer", "provider") pact.with_specification("V4") @@ -98,7 +97,7 @@ def a_message_interaction_is_being_defined_for_a_consumer_test() -> None: @given("the message interaction is marked as pending") def the_message_interaction_is_marked_as_pending( - pact_interaction: PactInteraction + pact_interaction: PactInteraction, ) -> None: """The message interaction is marked as pending.""" pact_interaction.interaction.set_pending(pending=True) @@ -109,20 +108,14 @@ def the_message_interaction_is_marked_as_pending( ################################################################################ -@when( - "the Pact file for the test is generated", - target_fixture="pact_data" -) +@when("the Pact file for the test is generated", target_fixture="pact_data") def the_pact_file_for_the_test_is_generated( - pact_interaction: PactInteraction, - temp_dir: Path -) -> None: + pact_interaction: PactInteraction, temp_dir: Path +) -> Generator[Any, Any, None]: """The Pact file for the test is generated.""" (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) pact_interaction.pact.write_file(temp_dir / "pacts") - with ( - temp_dir / "pacts" / "consumer-provider.json" - ).open() as file: + with (temp_dir / "pacts" / "consumer-provider.json").open() as file: yield json.load(file) @@ -155,6 +148,7 @@ def the_interaction_in_the_pact_file_will_have_a_key_of( else: assert interaction[key] == value + @then( parsers.re( r"the (?P[^ ]+) interaction in the Pact file" @@ -172,4 +166,3 @@ def the_interaction_in_the_pact_file_will_container_provider_states( assert len(pact_data["interactions"]) >= num interaction = pact_data["interactions"][num - 1] assert interaction["type"] == interaction_type - From f777d0ce879d0692cf58cc77291f080ffc7ecf5e Mon Sep 17 00:00:00 2001 From: valkolovos Date: Mon, 10 Jun 2024 18:38:33 -0600 Subject: [PATCH 29/61] first pass at fixing tests --- src/pact/v3/ffi.py | 6 ------ .../interaction/_async_message_interaction.py | 2 ++ .../test_v3_message_consumer.py | 21 +++++++++++-------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index 6835d7797..d887e9b05 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -615,12 +615,6 @@ def __repr__(self) -> str: """ 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. diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index a5c971527..2e4dc2748 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -171,3 +171,5 @@ class AsyncMessageInteractionResult: contents: str | None = None metadata: Dict[str, str] | None = None response: Any | None = None + generators: Dict[str, Any] | None = None + matchingRules: Dict[str, Any] | None = None diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py index 3a5a227e3..99fddbc16 100644 --- a/tests/v3/compatibility_suite/test_v3_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -269,7 +269,7 @@ def handler( (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) pact_interaction.pact.write_file(temp_dir / "pacts") with (temp_dir / "pacts" / "consumer-provider.json").open() as file: - yield PactResult(received_payload["data"], json.load(file), None) + yield PactResult(received_payload.get("data"), json.load(file), None) ################################################################################ @@ -334,7 +334,12 @@ def the_first_message_in_the_pact_file_will_contain_provider_state( if not isinstance(messages, list) or not messages: msg = "No messages found" raise RuntimeError(msg) - assert state in messages[0]["providerStates"] + found = False + for provider_state in messages[0]["providerStates"]: + if state in list(provider_state.values()): + found = True + break + assert found @then( @@ -436,7 +441,7 @@ def the_message_contents_will_have_been_replaced_with( ) -> None: """The message contents for "$.one" will have been replaced with an "integer".""" elem_key = replace_token.split(".")[1] - value = pact_result.received_payload.message.get(elem_key) + value = json.loads(pact_result.received_payload.message).get(elem_key) assert compare_type(expected_type, value) @@ -481,11 +486,8 @@ def the_provider_state_for_the_message_will_contain_the_following_parameters( assert provider_state_params is not None found = {k: False for k in expected_params} for k, v in expected_params.items(): - for provider_state_param in provider_state_params: - if provider_state_param.get(k): - assert ast.literal_eval(provider_state_param[k]) == v - found[k] = True - break + assert ast.literal_eval(provider_state_params.get(k)) == v + found[k] = True assert all(found.values()) @@ -497,6 +499,7 @@ def the_received_message_content_type_will_be( content_type: str, ) -> None: """The received message content type will be "application/json".""" + import pdb; pdb.set_trace() assert pact_result.received_payload.context.get("contentType") == content_type @@ -555,7 +558,7 @@ def the_received_message_payload_will_contain_the_basic_json_document( pact_result: PactResult, json_doc: str ) -> None: """The received message payload will contain the "basic" JSON document.""" - assert pact_result.received_payload.message == read_json(f"{json_doc}.json") + assert json.loads(pact_result.received_payload.message) == read_json(f"{json_doc}.json") def read_json(file: str) -> dict[str, Any]: From ed267823e8432db809375cbb74c1b304d726d9bd Mon Sep 17 00:00:00 2001 From: valkolovos Date: Mon, 10 Jun 2024 18:46:22 -0600 Subject: [PATCH 30/61] remove 'import pdb' --- tests/v3/compatibility_suite/test_v3_message_consumer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py index 99fddbc16..fb66b6b21 100644 --- a/tests/v3/compatibility_suite/test_v3_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -499,7 +499,6 @@ def the_received_message_content_type_will_be( content_type: str, ) -> None: """The received message content type will be "application/json".""" - import pdb; pdb.set_trace() assert pact_result.received_payload.context.get("contentType") == content_type From 4e13c37011fbd0e0d2905373d9f0c4e6fc054b4c Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 12 Jun 2024 09:24:02 +1000 Subject: [PATCH 31/61] chore(ffi): remove message handle Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 159 --------------------------------------------- 1 file changed, 159 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index d887e9b05..f73a8dbb7 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -208,9 +208,6 @@ def __repr__(self) -> str: class MessageContents: ... -class MessageHandle: ... - - class MessageMetadataIterator: ... @@ -6268,162 +6265,6 @@ def new_message_pact(consumer_name: str, provider_name: str) -> MessagePactHandl raise NotImplementedError -def new_message(pact: MessagePactHandle, description: str) -> MessageHandle: - """ - Creates a new Message and returns a handle to it. - - [Rust - `pactffi_new_message`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_new_message) - - * `description` - The message description. It needs to be unique for each - Message. - - Returns a new `MessageHandle`. - """ - raise NotImplementedError - - -def message_expects_to_receive(message: MessageHandle, description: str) -> None: - """ - Sets the description for the Message. - - [Rust - `pactffi_message_expects_to_receive`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_expects_to_receive) - - * `description` - The message description. It needs to be unique for each - message. - """ - raise NotImplementedError - - -def message_given(message: MessageHandle, description: str) -> None: - """ - Adds a provider state to the Interaction. - - [Rust - `pactffi_message_given`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_given) - - * `description` - The provider state description. It needs to be unique for - each message - """ - raise NotImplementedError - - -def message_given_with_param( - message: MessageHandle, - description: str, - name: str, - value: str, -) -> None: - """ - Adds a provider state to the Message with a parameter key and value. - - [Rust - `pactffi_message_given_with_param`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_given_with_param) - - * `description` - The provider state description. It needs to be unique. - * `name` - Parameter name. - * `value` - Parameter value. - """ - raise NotImplementedError - - -def message_with_contents( - message_handle: MessageHandle, - content_type: str, - body: str | bytes, -) -> None: - """ - Adds the contents of the Message. - - [Rust - `pactffi_message_with_contents`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_with_contents) - - Accepts JSON, binary and other payload types. Binary data will be base64 - encoded when serialised. - - Args: - message_handle: - Handle to the Message. - - body: - The body contents. For JSON payloads, matching rules can be embedded - in the body. See [JSON matching - rule](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.19/rust/pact_ffi/IntegrationJson.md). - - content_type: - The content type of the body. - """ - body = body.encode("utf-8") if isinstance(body, str) else body - lib.pactffi_message_with_contents( - message_handle._ref, # type: ignore[attr-defined] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 - content_type.encode("utf-8"), - body, - len(body), - ) - - -def message_with_metadata_v2( - message_handle: MessageHandle, - key: str, - value: str, -) -> None: - """ - Adds expected metadata to the Message. - - [Rust - `pactffi_message_with_metadata_v2`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_with_metadata_v2) - - Args: - message_handle: - Handle to the Message. - - key: - Metadata key. - - value: - Metadata 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.4.19/rust/pact_ffi/IntegrationJson.md). - - To include matching rules for the metadata, include the matching rule JSON - format with the value as a single JSON document. I.e. - - ```python - message_with_metadata_v2( - handle, - "contentType", - json.dumps({ - "pact:matcher:type": "regex", - "regex": "text/.*", - }), - ) - ``` - - See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.19/rust/pact_ffi/IntegrationJson.md). - """ - lib.pactffi_message_with_metadata_v2( - message_handle._ref, # type: ignore[attr-defined] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 - key.encode("utf-8"), - value.encode("utf-8"), - ) - - -def message_reify(message_handle: MessageHandle) -> OwnedString: - """ - Reifies the given message. - - [Rust - `pactffi_message_reify`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_reify) - - Reification is the process of stripping away any matchers, and returning the - original contents. - """ - return OwnedString(lib.pactffi_message_reify(message_handle._ref)) # type: ignore[attr-defined] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 - - def write_message_pact_file( pact: MessagePactHandle, directory: str, From 1b14be1b82f374c9f396fdb0d41e6cb298a6184a Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 12 Jun 2024 11:43:58 +1000 Subject: [PATCH 32/61] chore(v3): add with_metadata Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 68 +++++++++++++++++++ .../interaction/_async_message_interaction.py | 44 ------------ src/pact/v3/interaction/_base.py | 51 ++++++++++++++ 3 files changed, 119 insertions(+), 44 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index f73a8dbb7..b08546373 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -5594,6 +5594,74 @@ def with_pact_metadata( raise RuntimeError(msg) +def with_metadata( + interaction: InteractionHandle, + key: str, + value: str, + 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.4.19/rust/pact_ffi/IntegrationJson.md) + + # Note + + For HTTP interactions, use [`with_header_v2`][pact.v3.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.v3.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. + """ + success: bool = lib.pactffi_with_metadata( + interaction._ref, + key.encode("utf-8"), + value.encode("utf-8"), + 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, diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index 2e4dc2748..c772acee0 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -105,50 +105,6 @@ def with_content( ) return self - def with_metadata( - self, - __metadata: dict[str, str] | None = None, - /, - **kwargs: str, - ) -> Self: - """ - Set the metadata of the message. - - 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 therefore): - - ```python - interaction.with_metadata({"key": "value", "key two": "value two"}) - interaction.with_metadata(foo="bar", baz="qux") - ``` - - !!! note - - The implementation treats the key `__metadata` as a special case. - Should there ever be a need to set metadata with the key - `__metadata`, it is must be passed through as a dictionary: - - ```python - interaction.with_metadata({"__metadata": "value"}) - ``` - - Args: - metadata: - Dictionary of metadata keys and associated values. - - **kwargs: - Additional metadata key-value pairs. - - Returns: - The current instance of the interaction. - """ - for k, v in (__metadata or {}).items(): - pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 - for k, v in kwargs.items(): - pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 - return self - def verify( self, handler: Callable[[Any, dict[str, Any]], Any] ) -> AsyncMessageInteractionResult | None: diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index ec95d3780..3875efa85 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -308,6 +308,57 @@ def with_binary_body( ) return self + def with_metadata( + self, + __metadata: dict[str, str] | None = None, + __part: Literal["Request", "Response"] | None = None, + /, + **kwargs: str, + ) -> Self: + """ + Set 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 therefore): + + ```python + interaction.with_metadata({"key": "value", "key two": "value two"}) + interaction.with_metadata(foo="bar", baz="qux") + ``` + + !!! note + + There are two special keys which cannot be used as keyword + arguments: `__metadata` and `__part`. Should there ever be a need + to set metadata with one of these keys, they must be passed through + as a dictionary: + + ```python + interaction.with_metadata({"__metadata": "value", "__part": 1}) + ``` + + 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. + """ + for k, v in (__metadata or {}).items(): + pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 + for k, v in kwargs.items(): + pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 + return self + def with_multipart_file( # noqa: PLR0913 self, part_name: str, From 73f82fc9a9336807179a95216db4510e2c2f33ae Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 12 Jun 2024 11:51:45 +1000 Subject: [PATCH 33/61] style: use snake_case Signed-off-by: JP-Ellis --- src/pact/v3/interaction/_async_message_interaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index c772acee0..d4b3fb370 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -128,4 +128,4 @@ class AsyncMessageInteractionResult: metadata: Dict[str, str] | None = None response: Any | None = None generators: Dict[str, Any] | None = None - matchingRules: Dict[str, Any] | None = None + matching_rules: Dict[str, Any] | None = None From 980565bc26cd249d362fff4f081a1507cc18f697 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 12 Jun 2024 11:59:05 +1000 Subject: [PATCH 34/61] chore: remove with_content The functionality is covered by the existing `with_body` Signed-off-by: JP-Ellis --- examples/tests/v3/test_01_message_consumer.py | 8 ++--- .../interaction/_async_message_interaction.py | 35 ------------------- .../test_v3_message_consumer.py | 8 +++-- 3 files changed, 9 insertions(+), 42 deletions(-) diff --git a/examples/tests/v3/test_01_message_consumer.py b/examples/tests/v3/test_01_message_consumer.py index bc7efd390..b7fe1368e 100644 --- a/examples/tests/v3/test_01_message_consumer.py +++ b/examples/tests/v3/test_01_message_consumer.py @@ -41,7 +41,7 @@ def pact() -> Generator[Pact, None, None]: 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_content` and + 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 @@ -58,7 +58,7 @@ def pact() -> Generator[Pact, None, None]: .with_specification("V3") .upon_receiving("a request", "Async") \ .given("a request to write test.txt") \ - .with_content(msg) \ + .with_body(msg) \ .with_metadata({"Content-Type": "application/json"}) .verify(pact_handler) ) @@ -114,7 +114,7 @@ def test_async_message_handler_write( processed_message = ( pact.upon_receiving("a write request", "Async") .given("a request to write test.txt") - .with_content(json.dumps(async_message)) + .with_body(json.dumps(async_message)) .verify(pact_handler) ) actual_handler.fs.write.assert_called_once_with( # type: ignore[attr-defined] @@ -148,7 +148,7 @@ def test_async_message_handler_read( processed_message = ( pact.upon_receiving("a read request", "Async") .given("a request to read test.txt") - .with_content(json.dumps(async_message)) + .with_body(json.dumps(async_message)) .verify(pact_handler) ) actual_handler.fs.read.assert_called_once_with( # type: ignore[attr-defined] diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index d4b3fb370..01b3c3c68 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -8,8 +8,6 @@ from dataclasses import dataclass from typing import Any, Callable, Dict -from typing_extensions import Self - import pact.v3.ffi from pact.v3.interaction._base import Interaction @@ -72,39 +70,6 @@ def _interaction_part(self) -> pact.v3.ffi.InteractionPart: """ return pact.v3.ffi.InteractionPart.REQUEST - def with_content( - self, - content: str | bytes, - content_type: str = "text/plain", - ) -> Self: - """ - Set the content of the message. - - Args: - content: - The message content, as a string or bytes. - - This can be any content that the consumer expects to receive, - whether it be plain text, JSON, XML, or some binary format. - - Binary payloads are encoded as base64 strings when serialised. - - JSON payloads may embeded [JSON matching - rules](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.19/rust/pact_ffi/IntegrationJson.md). - - content_type: - The content type of the message. - - Returns: - The current instance of the interaction. - """ - pact.v3.ffi.message_with_contents( - self._handle, # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 - content_type, - content, - ) - return self - def verify( self, handler: Callable[[Any, dict[str, Any]], Any] ) -> AsyncMessageInteractionResult | None: diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py index fb66b6b21..aec09bc56 100644 --- a/tests/v3/compatibility_suite/test_v3_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -207,7 +207,7 @@ def the_message_is_configured_with_the_following( _build_metadata_generator(generator_json, metadata_json) else: raise UnknownGeneratorCategoryError(category) - pact_interaction.interaction.with_content(json.dumps(body_json)) + pact_interaction.interaction.with_body(json.dumps(body_json)) for k, v in metadata_json.items(): v_str = v if isinstance(v, dict): @@ -222,7 +222,7 @@ def the_message_payload_contains_the_basic_json_document( pact_interaction: PactInteraction, json_doc: str ) -> None: """The message payload contains the "basic" JSON document.""" - pact_interaction.interaction.with_content(json.dumps(read_json(f"{json_doc}.json"))) + pact_interaction.interaction.with_body(json.dumps(read_json(f"{json_doc}.json"))) ################################################################################ @@ -557,7 +557,9 @@ def the_received_message_payload_will_contain_the_basic_json_document( pact_result: PactResult, json_doc: str ) -> None: """The received message payload will contain the "basic" JSON document.""" - assert json.loads(pact_result.received_payload.message) == read_json(f"{json_doc}.json") + assert json.loads(pact_result.received_payload.message) == read_json( + f"{json_doc}.json" + ) def read_json(file: str) -> dict[str, Any]: From 55b42ea1e590a67fd1cf8245c647db63328c6a91 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 13 Jun 2024 09:35:00 +1000 Subject: [PATCH 35/61] chore: remove interaction verify Interaction should be done at the Pact level, not at each Interaction. Signed-off-by: JP-Ellis --- .../interaction/_async_message_interaction.py | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/src/pact/v3/interaction/_async_message_interaction.py b/src/pact/v3/interaction/_async_message_interaction.py index 01b3c3c68..15621650a 100644 --- a/src/pact/v3/interaction/_async_message_interaction.py +++ b/src/pact/v3/interaction/_async_message_interaction.py @@ -4,10 +4,6 @@ from __future__ import annotations -import json -from dataclasses import dataclass -from typing import Any, Callable, Dict - import pact.v3.ffi from pact.v3.interaction._base import Interaction @@ -69,28 +65,3 @@ def _interaction_part(self) -> pact.v3.ffi.InteractionPart: consumer of the message does not send any responses. """ return pact.v3.ffi.InteractionPart.REQUEST - - def verify( - self, handler: Callable[[Any, dict[str, Any]], Any] - ) -> AsyncMessageInteractionResult | None: - reified_msg = pact.v3.ffi.message_reify(self._handle) # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 - if not reified_msg: - return None - result = AsyncMessageInteractionResult(**json.loads(reified_msg)) - response = handler(result.contents or {}, result.metadata or {}) - result.response = response - return result - - -@dataclass -class AsyncMessageInteractionResult: - """ - Result of the message verification. - """ - - description: str - contents: str | None = None - metadata: Dict[str, str] | None = None - response: Any | None = None - generators: Dict[str, Any] | None = None - matching_rules: Dict[str, Any] | None = None From 2d92f869a43cede1ff4dbd5059f0f3a945e8ed89 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 13 Jun 2024 09:36:32 +1000 Subject: [PATCH 36/61] feat(ffi): use the new with_metadata We prefer the use of `InteractionHandle` over `MessageHandle`. The functionality to add metadata has been added to the FFI, so this function can make use of this now. Signed-off-by: JP-Ellis --- src/pact/v3/interaction/_base.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index 3875efa85..e45e39c0b 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -327,6 +327,11 @@ def with_metadata( 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. + !!! note There are two special keys which cannot be used as keyword @@ -353,10 +358,21 @@ def with_metadata( Returns: The current instance of the interaction. """ + part = self._parse_interaction_part(__part) for k, v in (__metadata or {}).items(): - pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 + pact.v3.ffi.with_metadata( + self._handle, + k, + v, + part, + ) for k, v in kwargs.items(): - pact.v3.ffi.message_with_metadata_v2(self._handle, k, v) # type: ignore[arg-type] # TODO: Check InteractionHandle vs MessageHandle # noqa: TD003 + pact.v3.ffi.with_metadata( + self._handle, + k, + v, + part, + ) return self def with_multipart_file( # noqa: PLR0913 From e56f0187183ff06f7ed115a837ab05688173cfe6 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Sun, 16 Jun 2024 18:36:08 +1000 Subject: [PATCH 37/61] chore(ffi): add enum type alias Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index b08546373..b8eaef9bb 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -89,7 +89,7 @@ import typing import warnings from enum import Enum -from typing import TYPE_CHECKING, Any, List +from typing import TYPE_CHECKING, Any, List, Literal from pact.v3._ffi import ffi, lib # type: ignore[import] @@ -103,6 +103,33 @@ 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. +""" + + +################################################################################ +# 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. From aafeeda13893a59e10b7f9c583eb8cfe043b3b61 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Sun, 16 Jun 2024 18:49:21 +1000 Subject: [PATCH 38/61] chore: implement ffi Large commit which implements quite a large number of FFI functions. Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 1223 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 1011 insertions(+), 212 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index b8eaef9bb..d87bdd5c3 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -90,6 +90,7 @@ import warnings from enum import Enum from typing import TYPE_CHECKING, Any, List, Literal +from typing import Generator as GeneratorType from pact.v3._ffi import ffi, lib # type: ignore[import] @@ -135,19 +136,237 @@ # to inform the type checker of the existence of these types. -class AsynchronousMessage: ... +class AsynchronousMessage: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Asynchronous Message. + + Args: + ptr: + CFFI data structure. + """ + if ffi.typeof(ptr).cname != "struct AsynchronousMessage *": + msg = ( + "ptr must be a struct AsynchronousMessage, got" + f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + 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. + """ + 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) + + @property + def contents(self) -> MessageContents | None: + """ + The contents of the message. + + This may be `None` if the message has no contents. + """ + return async_message_get_contents(self) class Consumer: ... -class Generator: ... +class Generator: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a generator value. + + Args: + ptr: + CFFI data structure. + """ + if ffi.typeof(ptr).cname != "struct Generator *": + msg = "ptr must be a struct Generator, got" f" {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: -class GeneratorCategoryIterator: ... + - 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. -class GeneratorKeyValuePair: ... + 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. + """ + 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. + """ + 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) + if isinstance(s, bytes): + s = s.decode("utf-8") + return s + + @property + def generator(self) -> Generator: + """ + Generator value. + """ + return Generator(self._ptr.generator) class HttpRequest: ... @@ -156,66 +375,379 @@ class HttpRequest: ... class HttpResponse: ... -class InteractionHandle: - """ - Handle to a HTTP Interaction. +class InteractionHandle: + """ + Handle to a HTTP Interaction. + + [Rust + `InteractionHandle`](https://docs.rs/pact_ffi/0.4.19/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. + """ + if ffi.typeof(ptr).cname != "struct MatchingRule *": + msg = "ptr must be a struct MatchingRule, got" f" {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. + """ + 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. + """ + 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) + 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) + + +class MatchingRuleResult: ... + + +class Message: + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Message. + + Args: + ptr: + CFFI data structure. + """ + if ffi.typeof(ptr).cname != "struct Message *": + msg = "ptr must be a struct Message, got" f" {ffi.typeof(ptr).cname}" + raise TypeError(msg) + self._ptr = ptr + + def __str__(self) -> str: + """ + Nice string representation. + """ + return "Message" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return f"Message({self._ptr!r})" + + def __del__(self) -> None: + """ + Destructor for the Message. + """ + message_delete(self) + + @property + def description(self) -> str: + """ + Description of this message interaction. + + This needs to be unique in the pact file. + """ + return message_get_description(self) + + def provider_states(self) -> GeneratorType[ProviderState, None, None]: + """ + Optional provider state for the interaction. + """ + yield from message_get_provider_state_iter(self) + + @property + def contents(self) -> str | bytes | None: + """ + The contents of the message. + """ + return message_get_contents(self) or message_get_contents_bin(self) + + def metadata(self) -> GeneratorType[MessageMetadataPair, None, None]: + """ + Metadata associated with this message. + """ + yield from message_get_metadata_iter(self) - [Rust - `InteractionHandle`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/mock_server/handles/struct.InteractionHandle.html) - """ - def __init__(self, ref: int) -> None: +class MessageContents: + def __init__(self, ptr: cffi.FFI.CData) -> None: """ - Initialise a new Interaction Handle. + Initialise a Message Contents. Args: - ref: - Reference to the Interaction Handle. + ptr: + CFFI data structure. """ - self._ref: int = ref + if ffi.typeof(ptr).cname != "struct MessageContents *": + msg = ( + "ptr must be a struct MessageContents, got" f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr def __str__(self) -> str: """ - String representation of the Interaction Handle. + Nice string representation. """ - return f"InteractionHandle({self._ref})" + return "MessageContents" def __repr__(self) -> str: """ - String representation of the Interaction Handle. + Debugging representation. """ - return f"InteractionHandle({self._ref!r})" + return f"MessageContents({self._ptr!r})" + @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) -class MatchingRule: ... + @property + def metadata(self) -> GeneratorType[MessageMetadataPair, None, None]: + """ + Get the metadata for the message contents. + """ + yield from message_contents_get_metadata_iter(self) + def matching_rules( + self, + category: GeneratorCategoryOptions | GeneratorCategory, + ) -> GeneratorType[MatchingRuleKeyValuePair, None, None]: + """ + Get the matching rules for the message contents. + """ + if isinstance(category, str): + category = GeneratorCategory(category.upper()) + yield from message_contents_get_matching_rule_iter(self, category) -class MatchingRuleCategoryIterator: ... + 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) -class MatchingRuleDefinitionResult: ... +class MessageMetadataIterator: + """ + Iterator over an interaction's metadata. + """ + def __init__(self, ptr: cffi.FFI.CData) -> None: + """ + Initialise a new Message Metadata Iterator. -class MatchingRuleIterator: ... + Args: + ptr: + CFFI data structure. + """ + 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" -class MatchingRuleKeyValuePair: ... + 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) -class MatchingRuleResult: ... + 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 Message: +class MessageMetadataPair: + """ + A metadata key-value pair. + """ + def __init__(self, ptr: cffi.FFI.CData) -> None: """ - Initialise a new Message. + Initialise a new Message Metadata Pair. Args: ptr: CFFI data structure. """ - if ffi.typeof(ptr).cname != "struct Message *": - msg = "ptr must be a struct Message, got" f" {ffi.typeof(ptr).cname}" + if ffi.typeof(ptr).cname != "struct MessageMetadataPair *": + msg = ( + "ptr must be a struct MessageMetadataPair, got" + f" {ffi.typeof(ptr).cname}" + ) raise TypeError(msg) self._ptr = ptr @@ -223,22 +755,39 @@ def __str__(self) -> str: """ Nice string representation. """ - return "Message" + return "MessageMetadataPair" def __repr__(self) -> str: """ Debugging representation. """ - return f"Message({self._ptr!r})" - - -class MessageContents: ... - + return f"MessageMetadataPair({self._ptr!r})" -class MessageMetadataIterator: ... + 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 -class MessageMetadataPair: ... + @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 MessagePact: ... @@ -268,6 +817,58 @@ class MismatchesIterator: ... class Pact: ... +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. + """ + 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. @@ -639,6 +1240,12 @@ def __repr__(self) -> str: """ 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. @@ -691,31 +1298,155 @@ def __del__(self) -> None: """ provider_state_param_iter_delete(self) - def __iter__(self) -> ProviderStateParamIterator: + 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. + """ + 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) -> None: + """ + Initialise a new Synchronous HTTP Interaction. + + Args: + ptr: + CFFI data structure. + """ + if ffi.typeof(ptr).cname != "struct SynchronousHttp *": + msg = ( + "ptr must be a struct SynchronousHttp, got" f" {ffi.typeof(ptr).cname}" + ) + raise TypeError(msg) + self._ptr = ptr + + 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. + """ + 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) + + @property + def request_contents(self) -> str | bytes | None: """ - Return the iterator itself. + The contents of the request. """ - return self + return sync_http_get_request_contents( + self + ) or sync_http_get_request_contents_bin(self) - def __next__(self) -> ProviderStateParamPair: + @property + def response_contents(self) -> str | bytes | None: """ - Get the next message from the iterator. + The contents of the response. """ - return provider_state_param_iter_next(self) + return sync_http_get_response_contents( + self + ) or sync_http_get_response_contents_bin(self) -class ProviderStateParamPair: +class SynchronousMessage: def __init__(self, ptr: cffi.FFI.CData) -> None: """ - Initialise a new ProviderStateParamPair. + Initialise a new Synchronous Message. Args: ptr: CFFI data structure. """ - if ffi.typeof(ptr).cname != "struct ProviderStateParamPair *": + if ffi.typeof(ptr).cname != "struct SynchronousMessage *": msg = ( - "ptr must be a struct ProviderStateParamPair, got" + "ptr must be a struct SynchronousMessage, got" f" {ffi.typeof(ptr).cname}" ) raise TypeError(msg) @@ -725,39 +1456,51 @@ def __str__(self) -> str: """ Nice string representation. """ - return "ProviderStateParamPair" + return "SynchronousMessage" def __repr__(self) -> str: """ Debugging representation. """ - return f"ProviderStateParamPair({self._ptr!r})" + return f"SynchronousMessage({self._ptr!r})" - @property - def key(self) -> str: + def __del__(self) -> None: """ - Provider State Param key. + Destructor for the SynchronousMessage. """ - s = ffi.string(self._ptr.key) # type: ignore[attr-defined] - if isinstance(s, bytes): - s = s.decode("utf-8") - return s + sync_message_delete(self) @property - def value(self) -> str: - """ - Provider State Param value. + def description(self) -> str: """ - s = ffi.string(self._ptr.value) # type: ignore[attr-defined] - if isinstance(s, bytes): - s = s.decode("utf-8") - return s + Description of this message interaction. + This needs to be unique in the pact file. + """ + return sync_message_get_description(self) -class SynchronousHttp: ... + def provider_states(self) -> GeneratorType[ProviderState, None, None]: + """ + Optional provider state for the interaction. + """ + yield from sync_message_get_provider_state_iter(self) + @property + def request_contents(self) -> MessageContents: + """ + The contents of the message. + """ + return sync_message_get_request_contents(self) -class SynchronousMessage: ... + @property + def response_contents(self) -> GeneratorType[MessageContents, None, None]: + """ + The contents of the responses. + """ + yield from ( + sync_message_get_response_contents(self, i) + for i in range(sync_message_get_number_responses(self)) + ) class VerifierHandle: @@ -1642,27 +2385,19 @@ def async_message_delete(message: AsynchronousMessage) -> None: [Rust `pactffi_async_message_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_async_message_delete) """ - raise NotImplementedError + lib.pactffi_async_message_delete(message) -def async_message_get_contents(message: AsynchronousMessage) -> MessageContents: +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.4.19/pact_ffi/?search=pactffi_async_message_get_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. + If the message contents are missing, this function will return `None`. """ - raise NotImplementedError + return MessageContents(lib.pactffi_async_message_get_contents(message)) def async_message_get_contents_str(message: AsynchronousMessage) -> str: @@ -1799,21 +2534,14 @@ def async_message_get_description(message: AsynchronousMessage) -> str: [Rust `pactffi_async_message_get_description`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_async_message_get_description) - # Safety - - The returned string must be deleted with `pactffi_string_delete`. - - Since it is a copy, the returned string may safely outlive the - `AsynchronousMessage`. - - # Errors - - On failure, this function will return a NULL pointer. - - This function may fail if the Rust string contains embedded null ('\0') - bytes. + Raises: + RuntimeError: If the description cannot be retrieved. """ - raise NotImplementedError + ptr = lib.pactffi_async_message_get_description(message) + if ptr == ffi.NULL: + msg = "Unable to get the description from the message." + raise RuntimeError(msg) + return OwnedString(ptr) def async_message_set_description( @@ -1878,12 +2606,10 @@ def async_message_get_provider_state_iter( # Safety The underlying data must not change during iteration. - - # Error Handling - - Returns NULL if an error occurs. """ - raise NotImplementedError + return ProviderStateIterator( + lib.pactffi_async_message_get_provider_state_iter(message) + ) def consumer_get_name(consumer: Consumer) -> str: @@ -1955,26 +2681,23 @@ def pact_consumer_delete(consumer: Consumer) -> None: raise NotImplementedError -def message_contents_get_contents_str(contents: MessageContents) -> str: +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.4.19/pact_ffi/?search=pactffi_message_contents_get_contents_str) - - # Safety - - The returned string must be deleted with `pactffi_string_delete`. - - The returned string can outlive the message. - - # Error Handling + [Rust + `pactffi_message_contents_get_contents_str`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_contents_get_contents_str) - If the message contents 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. + If the message has no contents or contain invalid UTF-8 characters, this + function will return `None`. """ - raise NotImplementedError + ptr = lib.pactffi_message_contents_get_contents_str(contents._ptr) + if ptr == ffi.NULL: + return None + s = ffi.string(ptr) + if isinstance(s, bytes): + s = s.decode("utf-8") + return s def message_contents_set_contents_str( @@ -2015,38 +2738,27 @@ def message_contents_get_contents_length(contents: MessageContents) -> int: [Rust `pactffi_message_contents_get_contents_length`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_contents_get_contents_length) - # Safety - - This function is safe. - - # Error Handling - - If the message is NULL, returns 0. If the body of the message - is missing, then this function also returns 0. + If the message has not contents, this function will return 0. """ - raise NotImplementedError + return lib.pactffi_message_contents_get_contents_length(contents._ptr) -def message_contents_get_contents_bin(contents: MessageContents) -> str: +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.4.19/pact_ffi/?search=pactffi_message_contents_get_contents_bin) - # Safety - - The number of bytes in the buffer will be returned by - `pactffi_message_contents_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. + If the message has no contents, this function will return `None`. """ - raise NotImplementedError + 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( @@ -2108,7 +2820,9 @@ def message_contents_get_metadata_iter( This function may fail if any of the Rust strings contain embedded null ('\0') bytes. """ - raise NotImplementedError + return MessageMetadataIterator( + lib.pactffi_message_contents_get_metadata_iter(contents._ptr) + ) def message_contents_get_matching_rule_iter( @@ -2149,7 +2863,9 @@ def message_contents_get_matching_rule_iter( On failure, this function will return a NULL pointer. """ - raise NotImplementedError + return MatchingRuleCategoryIterator( + lib.pactffi_message_contents_get_matching_rule_iter(contents._ptr, category) + ) def request_contents_get_matching_rule_iter( @@ -2248,7 +2964,9 @@ def message_contents_get_generators_iter( On failure, this function will return a NULL pointer. """ - raise NotImplementedError + return GeneratorCategoryIterator( + lib.pactffi_message_contents_get_generators_iter(contents, category) + ) def request_contents_get_generators_iter( @@ -2650,7 +3368,7 @@ def generator_to_json(generator: Generator) -> str: This function will fail if it is passed a NULL pointer, or the owner of the generator has been deleted. """ - raise NotImplementedError + return OwnedString(lib.pactffi_generator_to_json(generator._ptr)) def generator_generate_string(generator: Generator, context_json: str) -> str: @@ -2668,7 +3386,14 @@ def generator_generate_string(generator: Generator, context_json: str) -> str: If anything goes wrong, it will return a NULL pointer. """ - raise NotImplementedError + 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: @@ -2685,7 +3410,10 @@ def generator_generate_integer(generator: Generator, context_json: str) -> int: If anything goes wrong or the generator is not a type that can generate an integer value, it will return a zero value. """ - raise NotImplementedError + return lib.pactffi_generator_generate_integer( + generator._ptr, + context_json.encode("utf-8"), + ) def generators_iter_delete(iter: GeneratorCategoryIterator) -> None: @@ -2695,7 +3423,7 @@ def generators_iter_delete(iter: GeneratorCategoryIterator) -> None: [Rust `pactffi_generators_iter_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_generators_iter_delete) """ - raise NotImplementedError + lib.pactffi_generators_iter_delete(iter._ptr) def generators_iter_next(iter: GeneratorCategoryIterator) -> GeneratorKeyValuePair: @@ -2717,7 +3445,10 @@ def generators_iter_next(iter: GeneratorCategoryIterator) -> GeneratorKeyValuePa If no further data is present, returns NULL. """ - raise NotImplementedError + 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: @@ -2727,7 +3458,7 @@ def generators_iter_pair_delete(pair: GeneratorKeyValuePair) -> None: [Rust `pactffi_generators_iter_pair_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_generators_iter_pair_delete) """ - raise NotImplementedError + lib.pactffi_generators_iter_pair_delete(pair._ptr) def sync_http_new() -> SynchronousHttp: @@ -2777,7 +3508,7 @@ def sync_http_get_request(interaction: SynchronousHttp) -> HttpRequest: raise NotImplementedError -def sync_http_get_request_contents(interaction: SynchronousHttp) -> str: +def sync_http_get_request_contents(interaction: SynchronousHttp) -> str | None: """ Get the request contents of a `SynchronousHttp` interaction in string form. @@ -2797,7 +3528,13 @@ def sync_http_get_request_contents(interaction: SynchronousHttp) -> str: mechanism to differentiate with this function call alone between a NULL body and a missing body. """ - raise NotImplementedError + ptr = lib.pactffi_sync_http_get_request_contents(interaction._ptr) + if ptr == ffi.NULL: + return None + s = ffi.string(ptr) + if isinstance(s, bytes): + s = s.decode("utf-8") + return s def sync_http_set_request_contents( @@ -2848,7 +3585,7 @@ def sync_http_get_request_contents_length(interaction: SynchronousHttp) -> int: If the interaction is NULL, returns 0. If the body of the request is missing, then this function also returns 0. """ - raise NotImplementedError + return lib.pactffi_sync_http_get_request_contents_length(interaction._ptr) def sync_http_get_request_contents_bin(interaction: SynchronousHttp) -> bytes: @@ -2870,7 +3607,13 @@ def sync_http_get_request_contents_bin(interaction: SynchronousHttp) -> bytes: If the interaction is NULL, returns NULL. If the body of the request is missing, then this function also returns NULL. """ - raise NotImplementedError + 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( @@ -2925,7 +3668,7 @@ def sync_http_get_response(interaction: SynchronousHttp) -> HttpResponse: raise NotImplementedError -def sync_http_get_response_contents(interaction: SynchronousHttp) -> str: +def sync_http_get_response_contents(interaction: SynchronousHttp) -> str | None: """ Get the response contents of a `SynchronousHttp` interaction in string form. @@ -2946,7 +3689,13 @@ def sync_http_get_response_contents(interaction: SynchronousHttp) -> str: NULL. This means there's no mechanism to differentiate with this function call alone between a NULL body and a missing body. """ - raise NotImplementedError + ptr = lib.pactffi_sync_http_get_response_contents(interaction._ptr) + if ptr == ffi.NULL: + return None + s = ffi.string(ptr) + if isinstance(s, bytes): + s = s.decode("utf-8") + return s def sync_http_set_response_contents( @@ -2997,10 +3746,10 @@ def sync_http_get_response_contents_length(interaction: SynchronousHttp) -> int: If the interaction is NULL or the index is not valid, returns 0. If the body of the response is missing, then this function also returns 0. """ - raise NotImplementedError + return lib.pactffi_sync_http_get_response_contents_length(interaction._ptr) -def sync_http_get_response_contents_bin(interaction: SynchronousHttp) -> bytes: +def sync_http_get_response_contents_bin(interaction: SynchronousHttp) -> bytes | None: """ Get the response contents of a `SynchronousHttp` interaction as bytes. @@ -3019,7 +3768,13 @@ def sync_http_get_response_contents_bin(interaction: SynchronousHttp) -> bytes: If the interaction is NULL, returns NULL. If the body of the response is missing, then this function also returns NULL. """ - raise NotImplementedError + 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( @@ -3075,7 +3830,10 @@ def sync_http_get_description(interaction: SynchronousHttp) -> str: This function may fail if the Rust string contains embedded null ('\0') bytes. """ - raise NotImplementedError + s = ffi.string(lib.pactffi_sync_http_get_description(interaction._ptr)) + if isinstance(s, bytes): + s = s.decode("utf-8") + return s def sync_http_set_description(interaction: SynchronousHttp, description: str) -> int: @@ -3144,7 +3902,9 @@ def sync_http_get_provider_state_iter( Returns NULL if an error occurs. """ - raise NotImplementedError + return ProviderStateIterator( + lib.pactffi_sync_http_get_provider_state_iter(interaction._ptr) + ) def pact_interaction_as_synchronous_http( @@ -3261,6 +4021,29 @@ def pact_message_iter_next(iter: PactMessageIterator) -> Message: return Message(ptr) +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.4.19/pact_ffi/?search=pactffi_pact_async_message_iter_next) + """ + ptr = lib.pactffi_pact_async_message_iter_next(iter._ptr) + if ptr == ffi.NULL: + raise StopIteration + return AsynchronousMessage(ptr) + + +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.4.19/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. @@ -3271,7 +4054,6 @@ def pact_sync_message_iter_next(iter: PactSyncMessageIterator) -> SynchronousMes ptr = lib.pactffi_pact_sync_message_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration - raise NotImplementedError return SynchronousMessage(ptr) @@ -3347,7 +4129,7 @@ def matching_rule_to_json(rule: MatchingRule) -> str: 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. """ - raise NotImplementedError + return OwnedString(lib.pactffi_matching_rule_to_json(rule._ptr)) def matching_rules_iter_delete(iter: MatchingRuleCategoryIterator) -> None: @@ -3357,7 +4139,7 @@ def matching_rules_iter_delete(iter: MatchingRuleCategoryIterator) -> None: [Rust `pactffi_matching_rules_iter_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_matching_rules_iter_delete) """ - raise NotImplementedError + lib.pactffi_matching_rules_iter_delete(iter._ptr) def matching_rules_iter_next( @@ -3381,7 +4163,7 @@ def matching_rules_iter_next( If no further data is present, returns NULL. """ - raise NotImplementedError + return MatchingRuleKeyValuePair(lib.pactffi_matching_rules_iter_next(iter._ptr)) def matching_rules_iter_pair_delete(pair: MatchingRuleKeyValuePair) -> None: @@ -3391,7 +4173,7 @@ def matching_rules_iter_pair_delete(pair: MatchingRuleKeyValuePair) -> None: [Rust `pactffi_matching_rules_iter_pair_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_matching_rules_iter_pair_delete) """ - raise NotImplementedError + lib.pactffi_matching_rules_iter_pair_delete(pair._ptr) def message_new() -> Message: @@ -3459,7 +4241,7 @@ def message_delete(message: Message) -> None: [Rust `pactffi_message_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_delete) """ - raise NotImplementedError + lib.pactffi_message_delete(message._ptr) def message_get_contents(message: Message) -> OwnedString | None: @@ -3469,23 +4251,14 @@ def message_get_contents(message: Message) -> OwnedString | None: [Rust `pactffi_message_get_contents`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_get_contents) - # Safety - - The returned string must be deleted with `pactffi_string_delete` and can - outlive the message. 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. - - 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. + Returns `None` if the message is NULL or the body is missing. As a result, + there is no way to differentiate between a NULL message and a missing body + from this method. """ - raise NotImplementedError + ptr = lib.pactffi_message_get_contents(message._ptr) + if ptr == ffi.NULL: + return None + return OwnedString(ptr) def message_set_contents(message: Message, contents: str, content_type: str) -> None: @@ -3517,38 +4290,20 @@ def message_get_contents_length(message: Message) -> int: [Rust `pactffi_message_get_contents_length`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_get_contents_length) - # Safety - - This function is safe. - - # Error Handling - - If the message is NULL, returns 0. If the body of the message is missing, - then this function also returns 0. + This function may return 0 if the message is NULL or the body is missing. """ - raise NotImplementedError + return lib.pactffi_message_get_contents_length(message._ptr) -def message_get_contents_bin(message: Message) -> str: +def message_get_contents_bin(message: Message) -> bytes | None: """ Get the contents of a `Message` as a pointer to an array of bytes. [Rust `pactffi_message_get_contents_bin`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_get_contents_bin) - - # Safety - - The number of bytes in the buffer will be returned by - `pactffi_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 + ptr = lib.pactffi_message_get_contents_bin(message._ptr) + return ffi.buffer(ptr, message_get_contents_length(message._ptr))[:] def message_set_contents_bin( @@ -3584,12 +4339,6 @@ def message_get_description(message: Message) -> OwnedString: [Rust `pactffi_message_get_description`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_get_description) - # Safety - - The returned string must be deleted with `pactffi_string_delete`. - - Since it is a copy, the returned string may safely outlive the `Message`. - # Errors On failure, this function will return a NULL pointer. @@ -3597,7 +4346,11 @@ def message_get_description(message: Message) -> OwnedString: This function may fail if the Rust string contains embedded null ('\0') bytes. """ - raise NotImplementedError + ptr = lib.pactffi_message_get_description(message._ptr) + if ptr == ffi.NULL: + msg = "Failed to get description" + raise ValueError(msg) + return OwnedString(ptr) def message_set_description(message: Message, description: str) -> int: @@ -3696,7 +4449,7 @@ def provider_state_iter_delete(iter: ProviderStateIterator) -> None: [Rust `pactffi_provider_state_iter_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_provider_state_iter_delete) """ - raise NotImplementedError + lib.pactffi_provider_state_iter_delete(iter._ptr) def message_find_metadata(message: Message, key: str) -> str: @@ -3765,7 +4518,7 @@ def message_metadata_iter_next(iter: MessageMetadataIterator) -> MessageMetadata If no further data is present, returns NULL. """ - raise NotImplementedError + return MessageMetadataPair(lib.pactffi_message_metadata_iter_next(iter._ptr)) def message_get_metadata_iter(message: Message) -> MessageMetadataIterator: @@ -3790,7 +4543,7 @@ def message_get_metadata_iter(message: Message) -> MessageMetadataIterator: This function may fail if any of the Rust strings contain embedded null ('\0') bytes. """ - raise NotImplementedError + return MessageMetadataIterator(lib.pactffi_message_get_metadata_iter(message._ptr)) def message_metadata_iter_delete(iter: MessageMetadataIterator) -> None: @@ -3800,7 +4553,7 @@ def message_metadata_iter_delete(iter: MessageMetadataIterator) -> None: [Rust `pactffi_message_metadata_iter_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_metadata_iter_delete) """ - raise NotImplementedError + lib.pactffi_message_metadata_iter_delete(iter._ptr) def message_metadata_pair_delete(pair: MessageMetadataPair) -> None: @@ -3810,7 +4563,7 @@ def message_metadata_pair_delete(pair: MessageMetadataPair) -> None: [Rust `pactffi_message_metadata_pair_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_metadata_pair_delete) """ - raise NotImplementedError + lib.pactffi_message_metadata_pair_delete(pair._ptr) def message_pact_new_from_json(file_name: str, json_str: str) -> MessagePact: @@ -4242,7 +4995,7 @@ def sync_message_delete(message: SynchronousMessage) -> None: [Rust `pactffi_sync_message_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_sync_message_delete) """ - raise NotImplementedError + lib.pactffi_sync_message_delete(message._ptr) def sync_message_get_request_contents_str(message: SynchronousMessage) -> str: @@ -4673,7 +5426,9 @@ def sync_message_get_provider_state_iter( Returns NULL if an error occurs. """ - raise NotImplementedError + return ProviderStateIterator( + lib.pactffi_sync_message_get_provider_state_iter(message._ptr) + ) def string_delete(string: OwnedString) -> None: @@ -5512,6 +6267,24 @@ def with_query_parameter_v2( ) ``` + 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. @@ -6294,6 +7067,32 @@ def pact_handle_get_message_iter(pact: PactHandle) -> PactMessageIterator: return PactMessageIterator(lib.pactffi_pact_handle_get_message_iter(pact._ref)) +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.4.19/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. From 466e107d1bc4d821388c72017b0f88a21f402011 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Sun, 16 Jun 2024 19:26:35 +1000 Subject: [PATCH 39/61] feat(v3): implement interactions iterator Signed-off-by: JP-Ellis --- src/pact/v3/pact.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index 40970215f..95b57563e 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -378,27 +378,27 @@ def messages(self) -> pact.v3.ffi.PactMessageIterator: def interactions( self, kind: Literal["HTTP"], - ) -> pact.v3.ffi.PactSyncHttpIterator: ... + ) -> Generator[pact.v3.ffi.SynchronousHttp, None, None]: ... @overload def interactions( self, kind: Literal["Sync"], - ) -> pact.v3.ffi.PactSyncMessageIterator: ... + ) -> Generator[pact.v3.ffi.SynchronousMessage, None, None]: ... @overload def interactions( self, kind: Literal["Async"], - ) -> pact.v3.ffi.PactMessageIterator: ... + ) -> Generator[pact.v3.ffi.AsynchronousMessage, None, None]: ... def interactions( self, kind: str = "HTTP", ) -> ( - pact.v3.ffi.PactSyncHttpIterator - | pact.v3.ffi.PactSyncMessageIterator - | pact.v3.ffi.PactMessageIterator + Generator[pact.v3.ffi.SynchronousHttp, None, None] + | Generator[pact.v3.ffi.SynchronousMessage, None, None] + | Generator[pact.v3.ffi.AsynchronousMessage, None, None] ): """ Return an iterator over the Pact's interactions. @@ -409,11 +409,11 @@ def interactions( # TODO: Add an iterator for `All` interactions. # https://github.com/pact-foundation/pact-python/issues/451 if kind == "HTTP": - return pact.v3.ffi.pact_handle_get_sync_http_iter(self._handle) + yield from pact.v3.ffi.pact_handle_get_sync_http_iter(self._handle) if kind == "Sync": - return pact.v3.ffi.pact_handle_get_sync_message_iter(self._handle) + yield from pact.v3.ffi.pact_handle_get_sync_message_iter(self._handle) if kind == "Async": - return pact.v3.ffi.pact_handle_get_message_iter(self._handle) + yield from pact.v3.ffi.pact_handle_get_async_message_iter(self._handle) msg = f"Unknown interaction type: {kind}" raise ValueError(msg) From ab3c02bac0d13f4be7c836246e8a73ec19e80db7 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Sun, 16 Jun 2024 19:28:09 +1000 Subject: [PATCH 40/61] chore(v3): remove messages iterator The `interactions` method provides the necessary functionality Signed-off-by: JP-Ellis --- src/pact/v3/pact.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index 95b57563e..ab0fb9c62 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -353,27 +353,6 @@ def serve( # noqa: PLR0913 verbose=verbose, ) - def messages(self) -> pact.v3.ffi.PactMessageIterator: - """ - Iterate over the messages in the Pact. - - This function returns an iterator over the messages in the Pact. This - is useful for validating the Pact against the provider. - - ```python - pact = Pact("consumer", "provider") - with pact.serve() as srv: - for message in pact.messages(): - # Validate the message against the provider. - ... - ``` - - Note that the Pact must be written to a file before the messages can be - iterated over. This is because the messages are not stored in memory, - but rather are streamed directly from the file. - """ - return pact.v3.ffi.pact_handle_get_message_iter(self._handle) - @overload def interactions( self, From 5aaf1a78188eb197c24b5fc8b368673dcec9ceca Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Sun, 16 Jun 2024 19:28:50 +1000 Subject: [PATCH 41/61] feat(v3): add verify method for pact messages Signed-off-by: JP-Ellis --- src/pact/v3/pact.py | 62 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index ab0fb9c62..5afea9155 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -64,10 +64,12 @@ import json import logging +import warnings from pathlib import Path from typing import ( TYPE_CHECKING, Any, + Callable, Generator, Literal, Set, @@ -396,6 +398,66 @@ def interactions( msg = f"Unknown interaction type: {kind}" raise ValueError(msg) + def verify( + self, + handler: Callable[[str | bytes | None, dict[str, str]], None], + kind: Literal["Async", "Sync"], + ) -> 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`. + """ + errors: list[tuple[(int, str, Exception)]] = [] + for idx, message in enumerate(self.interactions(kind)): + request = ( + message.contents + if isinstance(message, pact.v3.ffi.AsynchronousMessage) + else message.request_contents + ) + + if request is None: + warnings.warn(f"Message {idx} has no contents", stacklevel=2) + continue + + body = request.contents + metadata = {pair.key: pair.value for pair in request.metadata} + + try: + handler(body, metadata) + except Exception as e: # noqa: BLE001 + errors.append((idx, message.description, e)) + + if errors: + msg = "\n".join( + f"Message {index}: {description}: {e}" + for index, description, e in errors + ) + raise AssertionError(msg) + def write_file( self, directory: Path | str | None = None, From d64abb0e3a210838b55e23ae1f0f0c4dc70a7337 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Sun, 16 Jun 2024 19:30:00 +1000 Subject: [PATCH 42/61] chore(v3): remove get_provider_states The provider states don't make sense for pacts, as they are associated with the individual interactions, as opposed to the pact itself. Signed-off-by: JP-Ellis --- src/pact/v3/pact.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index 5afea9155..47ea3680c 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -490,20 +490,6 @@ def write_file( overwrite=overwrite, ) - def get_provider_states(self) -> Generator[dict[str, Any], Any, None]: - """ - Get the provider states for the interaction. - - Returns: - A list of provider states for the interaction. - """ - for message in self.messages(): - for provider_state in pact.v3.ffi.message_get_provider_state_iter(message): - yield { - "name": provider_state.name, - "params": provider_state.parameters, - } - class MismatchesError(Exception): """ From 7ab24c841d7376ba64f7c5c3e2d85570e7d33564 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Sun, 16 Jun 2024 19:51:36 +1000 Subject: [PATCH 43/61] fix(v3): various typing issues Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 26 ++++++++++++++++++-------- src/pact/v3/interaction/__init__.py | 6 +----- src/pact/v3/pact.py | 9 +++++++++ 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index d87bdd5c3..311ac5e47 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -127,6 +127,16 @@ Options. """ +MatchingRuleCategoryOptions = Literal[ + "METHOD", "method", + "PATH", "path", + "HEADER", "header", + "QUERY", "query", + "BODY", "body", + "STATUS", "status", + "CONTENTS", "contents", + "METADATA", "metadata", +] # fmt: skip ################################################################################ # Classes @@ -356,7 +366,7 @@ def path(self) -> str: """ Generator path. """ - s = ffi.string(self._ptr.path) + s = ffi.string(self._ptr.path) # type: ignore[attr-defined] if isinstance(s, bytes): s = s.decode("utf-8") return s @@ -366,7 +376,7 @@ def generator(self) -> Generator: """ Generator value. """ - return Generator(self._ptr.generator) + return Generator(self._ptr.generator) # type: ignore[attr-defined] class HttpRequest: ... @@ -534,7 +544,7 @@ def path(self) -> str: """ Matching Rule path. """ - s = ffi.string(self._ptr.path) + s = ffi.string(self._ptr.path) # type: ignore[attr-defined] if isinstance(s, bytes): s = s.decode("utf-8") return s @@ -544,7 +554,7 @@ def matching_rule(self) -> MatchingRule: """ Matching Rule value. """ - return MatchingRule(self._ptr.matching_rule) + return MatchingRule(self._ptr.matching_rule) # type: ignore[attr-defined] class MatchingRuleResult: ... @@ -657,13 +667,13 @@ def metadata(self) -> GeneratorType[MessageMetadataPair, None, None]: def matching_rules( self, - category: GeneratorCategoryOptions | GeneratorCategory, + category: MatchingRuleCategoryOptions | MatchingRuleCategory, ) -> GeneratorType[MatchingRuleKeyValuePair, None, None]: """ Get the matching rules for the message contents. """ if isinstance(category, str): - category = GeneratorCategory(category.upper()) + category = MatchingRuleCategory(category.upper()) yield from message_contents_get_matching_rule_iter(self, category) def generators( @@ -3588,7 +3598,7 @@ def sync_http_get_request_contents_length(interaction: SynchronousHttp) -> int: return lib.pactffi_sync_http_get_request_contents_length(interaction._ptr) -def sync_http_get_request_contents_bin(interaction: SynchronousHttp) -> bytes: +def sync_http_get_request_contents_bin(interaction: SynchronousHttp) -> bytes | None: """ Get the request contents of a `SynchronousHttp` interaction as bytes. @@ -4303,7 +4313,7 @@ def message_get_contents_bin(message: Message) -> bytes | None: `pactffi_message_get_contents_bin`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_message_get_contents_bin) """ ptr = lib.pactffi_message_get_contents_bin(message._ptr) - return ffi.buffer(ptr, message_get_contents_length(message._ptr))[:] + return ffi.buffer(ptr, message_get_contents_length(message))[:] def message_set_contents_bin( diff --git a/src/pact/v3/interaction/__init__.py b/src/pact/v3/interaction/__init__.py index 465c22dbc..661fed172 100644 --- a/src/pact/v3/interaction/__init__.py +++ b/src/pact/v3/interaction/__init__.py @@ -70,10 +70,7 @@ in the interaction. """ -from pact.v3.interaction._async_message_interaction import ( - AsyncMessageInteraction, - AsyncMessageInteractionResult, -) +from pact.v3.interaction._async_message_interaction import AsyncMessageInteraction from pact.v3.interaction._base import Interaction from pact.v3.interaction._http_interaction import HttpInteraction from pact.v3.interaction._sync_message_interaction import SyncMessageInteraction @@ -82,6 +79,5 @@ "Interaction", "HttpInteraction", "AsyncMessageInteraction", - "AsyncMessageInteractionResult", "SyncMessageInteraction", ] diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index 47ea3680c..a00e6feea 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -64,6 +64,7 @@ import json import logging +import typing import warnings from pathlib import Path from typing import ( @@ -433,6 +434,14 @@ def verify( """ errors: list[tuple[(int, str, Exception)]] = [] for idx, message in enumerate(self.interactions(kind)): + if TYPE_CHECKING: + # This is required to ensure that the type checker knows what + # type `message` is. + message = typing.cast( + pact.v3.ffi.SynchronousMessage | pact.v3.ffi.AsynchronousMessage, + message, + ) + request = ( message.contents if isinstance(message, pact.v3.ffi.AsynchronousMessage) From ee99af78671de0134d624c25d7420f0768beec6c Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 09:44:07 +1000 Subject: [PATCH 44/61] feat: optional freeing of memory Objects generated by the FFI may sometimes be owned by another object, in which case deleting them when they are out of scope in Python is invalid. The instantiators for these classes have been adjusted to take an optional `owned` keyword argument. If `owned` is `True`, then the `__del__` function for the class will do nothing. Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 85 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index 311ac5e47..e1d1a21a6 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -147,13 +147,18 @@ class AsynchronousMessage: - def __init__(self, ptr: cffi.FFI.CData) -> None: + 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. """ if ffi.typeof(ptr).cname != "struct AsynchronousMessage *": msg = ( @@ -162,6 +167,7 @@ def __init__(self, ptr: cffi.FFI.CData) -> None: ) raise TypeError(msg) self._ptr = ptr + self._owned = owned def __str__(self) -> str: """ @@ -179,7 +185,8 @@ def __del__(self) -> None: """ Destructor for the AsynchronousMessage. """ - async_message_delete(self) + if not self._owned: + async_message_delete(self) @property def description(self) -> str: @@ -561,18 +568,24 @@ class MatchingRuleResult: ... class Message: - def __init__(self, ptr: cffi.FFI.CData) -> None: + def __init__(self, ptr: cffi.FFI.CData, *, owned: bool = False) -> None: """ Initialise a new 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. """ if ffi.typeof(ptr).cname != "struct Message *": msg = "ptr must be a struct Message, got" f" {ffi.typeof(ptr).cname}" raise TypeError(msg) self._ptr = ptr + self._owned = owned def __str__(self) -> str: """ @@ -590,7 +603,8 @@ def __del__(self) -> None: """ Destructor for the Message. """ - message_delete(self) + if not self._owned: + message_delete(self) @property def description(self) -> str: @@ -619,16 +633,22 @@ def metadata(self) -> GeneratorType[MessageMetadataPair, None, None]: Metadata associated with this message. """ yield from message_get_metadata_iter(self) + return # Ensures that the parent object outlives the generator class MessageContents: - def __init__(self, ptr: cffi.FFI.CData) -> None: + 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. """ if ffi.typeof(ptr).cname != "struct MessageContents *": msg = ( @@ -636,6 +656,7 @@ def __init__(self, ptr: cffi.FFI.CData) -> None: ) raise TypeError(msg) self._ptr = ptr + self._owned = owned def __str__(self) -> str: """ @@ -649,6 +670,13 @@ def __repr__(self) -> str: """ 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: """ @@ -1378,13 +1406,18 @@ def value(self) -> str: class SynchronousHttp: - def __init__(self, ptr: cffi.FFI.CData) -> None: + 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. """ if ffi.typeof(ptr).cname != "struct SynchronousHttp *": msg = ( @@ -1392,6 +1425,7 @@ def __init__(self, ptr: cffi.FFI.CData) -> None: ) raise TypeError(msg) self._ptr = ptr + self._owned = owned def __str__(self) -> str: """ @@ -1409,7 +1443,8 @@ def __del__(self) -> None: """ Destructor for the SynchronousHttp. """ - sync_http_delete(self) + if not self._owned: + sync_http_delete(self) @property def description(self) -> str: @@ -1446,13 +1481,18 @@ def response_contents(self) -> str | bytes | None: class SynchronousMessage: - def __init__(self, ptr: cffi.FFI.CData) -> None: + 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. """ if ffi.typeof(ptr).cname != "struct SynchronousMessage *": msg = ( @@ -1461,6 +1501,7 @@ def __init__(self, ptr: cffi.FFI.CData) -> None: ) raise TypeError(msg) self._ptr = ptr + self._owned = owned def __str__(self) -> str: """ @@ -1478,7 +1519,8 @@ def __del__(self) -> None: """ Destructor for the SynchronousMessage. """ - sync_message_delete(self) + if not self._owned: + sync_message_delete(self) @property def description(self) -> str: @@ -2691,6 +2733,22 @@ def pact_consumer_delete(consumer: Consumer) -> None: 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.4.19/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. @@ -4028,7 +4086,7 @@ def pact_message_iter_next(iter: PactMessageIterator) -> Message: ptr = lib.pactffi_pact_message_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration - return Message(ptr) + return Message(ptr, owned=True) def pact_async_message_iter_next(iter: PactAsyncMessageIterator) -> AsynchronousMessage: @@ -4041,7 +4099,7 @@ def pact_async_message_iter_next(iter: PactAsyncMessageIterator) -> Asynchronous ptr = lib.pactffi_pact_async_message_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration - return AsynchronousMessage(ptr) + return AsynchronousMessage(ptr, owned=True) def pact_async_message_iter_delete(iter: PactAsyncMessageIterator) -> None: @@ -4064,7 +4122,7 @@ def pact_sync_message_iter_next(iter: PactSyncMessageIterator) -> SynchronousMes ptr = lib.pactffi_pact_sync_message_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration - return SynchronousMessage(ptr) + return SynchronousMessage(ptr, owned=True) def pact_sync_message_iter_delete(iter: PactSyncMessageIterator) -> None: @@ -4087,8 +4145,7 @@ def pact_sync_http_iter_next(iter: PactSyncHttpIterator) -> SynchronousHttp: ptr = lib.pactffi_pact_sync_http_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration - raise NotImplementedError - return SynchronousHttp(ptr) + return SynchronousHttp(ptr, owned=True) def pact_sync_http_iter_delete(iter: PactSyncHttpIterator) -> None: From d1c0d1b829061dfcaca15e34fbed4ca6cb3e335b Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 09:48:42 +1000 Subject: [PATCH 45/61] chore(ffi): minor fixes and implementations Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index e1d1a21a6..c4bb7e9e5 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -2437,7 +2437,7 @@ def async_message_delete(message: AsynchronousMessage) -> None: [Rust `pactffi_async_message_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_async_message_delete) """ - lib.pactffi_async_message_delete(message) + lib.pactffi_async_message_delete(message._ptr) def async_message_get_contents(message: AsynchronousMessage) -> MessageContents | None: @@ -2449,7 +2449,7 @@ def async_message_get_contents(message: AsynchronousMessage) -> MessageContents If the message contents are missing, this function will return `None`. """ - return MessageContents(lib.pactffi_async_message_get_contents(message)) + return MessageContents(lib.pactffi_async_message_get_contents(message._ptr)) def async_message_get_contents_str(message: AsynchronousMessage) -> str: @@ -2589,7 +2589,7 @@ def async_message_get_description(message: AsynchronousMessage) -> str: Raises: RuntimeError: If the description cannot be retrieved. """ - ptr = lib.pactffi_async_message_get_description(message) + 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) @@ -2660,7 +2660,7 @@ def async_message_get_provider_state_iter( The underlying data must not change during iteration. """ return ProviderStateIterator( - lib.pactffi_async_message_get_provider_state_iter(message) + lib.pactffi_async_message_get_provider_state_iter(message._ptr) ) @@ -2888,9 +2888,11 @@ def message_contents_get_metadata_iter( This function may fail if any of the Rust strings contain embedded null ('\0') bytes. """ - return MessageMetadataIterator( - lib.pactffi_message_contents_get_metadata_iter(contents._ptr) - ) + 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( @@ -4585,7 +4587,10 @@ def message_metadata_iter_next(iter: MessageMetadataIterator) -> MessageMetadata If no further data is present, returns NULL. """ - return MessageMetadataPair(lib.pactffi_message_metadata_iter_next(iter._ptr)) + ptr = lib.pactffi_message_metadata_iter_next(iter._ptr) + if ptr == ffi.NULL: + raise StopIteration + return MessageMetadataPair(ptr) def message_get_metadata_iter(message: Message) -> MessageMetadataIterator: @@ -5034,7 +5039,7 @@ def provider_state_param_pair_delete(pair: ProviderStateParamPair) -> None: [Rust `pactffi_provider_state_param_pair_delete`](https://docs.rs/pact_ffi/0.4.19/pact_ffi/?search=pactffi_provider_state_param_pair_delete) """ - raise NotImplementedError + lib.pactffi_provider_state_param_pair_delete(pair._ptr) def sync_message_new() -> SynchronousMessage: From 5107a59cf541ff8a3e77adae57b05579e3a7b44d Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 09:51:53 +1000 Subject: [PATCH 46/61] fix(ffi): ensure parent outline dependent objects The use of `yield from` may sometimes result in the parent instance being dropped, which then invalidates the underlying iterator. Adding a final `return` statement (even if it does nothing) ensures that Python finishes the `yield from` statement first before the function is finished. Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index c4bb7e9e5..9f46c584a 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -89,7 +89,7 @@ import typing import warnings from enum import Enum -from typing import TYPE_CHECKING, Any, List, Literal +from typing import TYPE_CHECKING, Any, List, Literal, Tuple from typing import Generator as GeneratorType from pact.v3._ffi import ffi, lib # type: ignore[import] @@ -202,6 +202,7 @@ 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: @@ -620,6 +621,7 @@ def provider_states(self) -> GeneratorType[ProviderState, None, None]: Optional provider state for the interaction. """ yield from message_get_provider_state_iter(self) + return # Ensures that the parent object outlives the generator @property def contents(self) -> str | bytes | None: @@ -692,6 +694,7 @@ 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, @@ -703,6 +706,7 @@ def matching_rules( 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, @@ -714,6 +718,7 @@ def generators( 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: @@ -1237,12 +1242,15 @@ def name(self) -> str: """ return provider_state_get_name(self) - @property - def parameters(self) -> dict[str, str]: + def parameters(self) -> GeneratorType[Tuple[str, str], None, None]: """ Provider State parameters. + + This is a generator that yields key-value pairs. """ - return {p.key: p.value for p in provider_state_get_param_iter(self)} + 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: @@ -1460,6 +1468,7 @@ 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: @@ -1536,6 +1545,7 @@ 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: @@ -1553,6 +1563,7 @@ def response_contents(self) -> GeneratorType[MessageContents, None, None]: sync_message_get_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: From d118431f3edae30da1fb54a08c5e86be27cdbc7d Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 09:55:32 +1000 Subject: [PATCH 47/61] feat(ffi): add `generate_contents` methods The former `get_contents` FFI return the raw body of the interaction, including matching rules and generators, which aren't very useful when verifying a message consume. The new methods processes the payload, replacing the matching rules and generators with values as would be actually expected by the consumer. Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 90 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index 9f46c584a..f25a2012b 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -211,7 +211,7 @@ def contents(self) -> MessageContents | None: This may be `None` if the message has no contents. """ - return async_message_get_contents(self) + return async_message_generate_contents(self) class Consumer: ... @@ -1552,7 +1552,7 @@ def request_contents(self) -> MessageContents: """ The contents of the message. """ - return sync_message_get_request_contents(self) + return sync_message_generate_request_contents(self) @property def response_contents(self) -> GeneratorType[MessageContents, None, None]: @@ -1560,7 +1560,7 @@ def response_contents(self) -> GeneratorType[MessageContents, None, None]: The contents of the responses. """ yield from ( - sync_message_get_response_contents(self, i) + 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 @@ -2463,6 +2463,28 @@ def async_message_get_contents(message: AsynchronousMessage) -> MessageContents 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.4.19/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. @@ -5229,6 +5251,36 @@ def sync_message_get_request_contents(message: SynchronousMessage) -> MessageCon 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.4.19/pact_ffi/?search=pactffi_sync_message_generate_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. + """ + return MessageContents( + lib.pactffi_sync_message_generate_request_contents(message._ptr), + owned=False, + ) + + def sync_message_get_number_responses(message: SynchronousMessage) -> int: """ Get the number of response messages in the `SynchronousMessage`. @@ -5419,6 +5471,38 @@ def sync_message_get_response_contents( 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.4.19/pact_ffi/?search=pactffi_sync_message_generate_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. + """ + return MessageContents( + lib.pactffi_sync_message_generate_response_contents(message._ptr, index), + owned=False, + ) + + def sync_message_get_description(message: SynchronousMessage) -> str: r""" Get a copy of the description. From db2d343d2eee1a176ed8b3c98cc1cf1bc181fe7c Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 09:58:29 +1000 Subject: [PATCH 48/61] feat(v3): add with_generators Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 38 ++++++++++++++++++++++++++++++++ src/pact/v3/interaction/_base.py | 33 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index f25a2012b..a21908aeb 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -7011,6 +7011,44 @@ def with_matching_rules( 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.4.19/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. + + """ + 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, diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index e45e39c0b..9696f37a3 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -551,3 +551,36 @@ def with_matching_rules( 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.v3.ffi.with_generators( + self._handle, + self._parse_interaction_part(part), + generators, + ) + return self From aeb2e9583accb3c113d1ec056fc0f869da496d77 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 09:58:45 +1000 Subject: [PATCH 49/61] chore(ffi): minor changes Signed-off-by: JP-Ellis --- src/pact/v3/ffi.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index a21908aeb..b44363c42 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -1227,7 +1227,7 @@ def __str__(self) -> str: """ Nice string representation. """ - return "ProviderState" + return "ProviderState({self.name!r})" def __repr__(self) -> str: """ @@ -2677,7 +2677,11 @@ def async_message_get_provider_state( 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 + 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( @@ -8052,9 +8056,11 @@ def verifier_broker_source_with_selectors( # noqa: PLR0913 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, + ( + 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, From dccb863a3f4f27c48e067185868d8e7801f071aa Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 09:59:55 +1000 Subject: [PATCH 50/61] feat(v3): add new exception types The exceptions are to be returned during verification. Signed-off-by: JP-Ellis --- src/pact/v3/pact.py | 178 +++++++++++++++++++++++++++++++++----------- 1 file changed, 135 insertions(+), 43 deletions(-) diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index a00e6feea..d5c0e2ff6 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -71,7 +71,9 @@ TYPE_CHECKING, Any, Callable, + Dict, Generator, + List, Literal, Set, overload, @@ -97,6 +99,94 @@ logger = logging.getLogger(__name__) +class InteractionVerificationError(Exception): + """ + Exception raised due during the verification of an interaction. + """ + + 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}") + 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(Exception): + """ + Exception raised due to errors in the verification of the Pact. + + This is raised when performing manual verification of the Pact, as opposed + to the automatic verification that is performed 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 MismatchesError(Exception): + """ + Exception raised when there are mismatches between the Pact and the server. + """ + + def __init__(self, mismatches: list[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 = mismatches + + # TODO: Replace the list of dicts with a more structured object. + # https://github.com/pact-foundation/pact-python/issues/644 + @property + def mismatches(self) -> list[dict[str, Any]]: + """ + Mismatches between the Pact and the server. + """ + return self._mismatches + + class Pact: """ A Pact between a consumer and a provider. @@ -399,11 +489,30 @@ def interactions( msg = f"Unknown interaction type: {kind}" raise ValueError(msg) + @overload def verify( self, - handler: Callable[[str | bytes | None, dict[str, str]], None], + handler: Callable[[str | bytes | None, Dict[str, str]], None], kind: Literal["Async", "Sync"], - ) -> None: + *, + raises: Literal[True] = True, + ) -> None: ... + @overload + def verify( + self, + handler: Callable[[str | bytes | None, Dict[str, str]], None], + kind: Literal["Async", "Sync"], + *, + raises: Literal[False], + ) -> List[InteractionVerificationError]: ... + + def verify( + self, + handler: Callable[[str | bytes | None, Dict[str, str]], None], + kind: Literal["Async", "Sync"], + *, + raises: bool = True, + ) -> List[InteractionVerificationError] | None: """ Verify message interactions. @@ -431,9 +540,14 @@ def verify( 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. """ - errors: list[tuple[(int, str, Exception)]] = [] - for idx, message in enumerate(self.interactions(kind)): + errors: List[InteractionVerificationError] = [] + for message in self.interactions(kind): if TYPE_CHECKING: # This is required to ensure that the type checker knows what # type `message` is. @@ -442,14 +556,19 @@ def verify( message, ) - request = ( - message.contents - if isinstance(message, pact.v3.ffi.AsynchronousMessage) - else message.request_contents - ) + if isinstance(message, pact.v3.ffi.SynchronousMessage): + request = message.request_contents + elif isinstance(message, pact.v3.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 {idx} has no contents", stacklevel=2) + warnings.warn( + f"Message '{message.description}' has no contents", + stacklevel=2, + ) continue body = request.contents @@ -458,14 +577,13 @@ def verify( try: handler(body, metadata) except Exception as e: # noqa: BLE001 - errors.append((idx, message.description, e)) + errors.append(InteractionVerificationError(message.description, e)) - if errors: - msg = "\n".join( - f"Message {index}: {description}: {e}" - for index, description, e in errors - ) - raise AssertionError(msg) + if raises: + if errors: + raise PactVerificationError(errors) + return None + return errors def write_file( self, @@ -500,32 +618,6 @@ def write_file( ) -class MismatchesError(Exception): - """ - Exception raised when there are mismatches between the Pact and the server. - """ - - def __init__(self, mismatches: list[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 = mismatches - - # TODO: Replace the list of dicts with a more structured object. - # https://github.com/pact-foundation/pact-python/issues/644 - @property - def mismatches(self) -> list[dict[str, Any]]: - """ - Mismatches between the Pact and the server. - """ - return self._mismatches - - class PactServer: """ Pact Server. From c3ddcd5466676eeadc1fa0900088dbb2e36ec0dc Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 10:02:40 +1000 Subject: [PATCH 51/61] fix(v3): interactions iterator Signed-off-by: JP-Ellis --- src/pact/v3/pact.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index d5c0e2ff6..4c2df73ef 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -466,7 +466,7 @@ def interactions( def interactions( self, - kind: str = "HTTP", + kind: Literal["HTTP", "Sync", "Async"] = "HTTP", ) -> ( Generator[pact.v3.ffi.SynchronousHttp, None, None] | Generator[pact.v3.ffi.SynchronousMessage, None, None] @@ -482,12 +482,14 @@ def interactions( # https://github.com/pact-foundation/pact-python/issues/451 if kind == "HTTP": yield from pact.v3.ffi.pact_handle_get_sync_http_iter(self._handle) - if kind == "Sync": + elif kind == "Sync": yield from pact.v3.ffi.pact_handle_get_sync_message_iter(self._handle) - if kind == "Async": + elif kind == "Async": yield from pact.v3.ffi.pact_handle_get_async_message_iter(self._handle) - msg = f"Unknown interaction type: {kind}" - raise ValueError(msg) + else: + msg = f"Unknown interaction type: {kind}" + raise ValueError(msg) + return # Ensures that the parent object outlives the generator @overload def verify( From 6f0eff68cd02a169676f88c7a359e7645ab2be00 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 10:05:39 +1000 Subject: [PATCH 52/61] chore(test): use named tuple more broadly The named tuple provides an improved experience for developers. Signed-off-by: JP-Ellis --- .../compatibility_suite/test_v3_consumer.py | 14 ++++++------- .../compatibility_suite/test_v4_consumer.py | 21 ++++++++----------- tests/v3/compatibility_suite/util/__init__.py | 17 ++++++++++++++- tests/v3/compatibility_suite/util/consumer.py | 5 +++-- 4 files changed, 35 insertions(+), 22 deletions(-) diff --git a/tests/v3/compatibility_suite/test_v3_consumer.py b/tests/v3/compatibility_suite/test_v3_consumer.py index b7014d6f7..f2f218a3f 100644 --- a/tests/v3/compatibility_suite/test_v3_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_consumer.py @@ -10,7 +10,7 @@ from pytest_bdd import given, parsers, scenario, then from pact.v3.pact import HttpInteraction, Pact -from tests.v3.compatibility_suite.util import parse_markdown_table +from tests.v3.compatibility_suite.util import PactInteractionTuple, parse_markdown_table from tests.v3.compatibility_suite.util.consumer import ( the_pact_file_for_the_test_is_generated, ) @@ -48,21 +48,21 @@ def test_supports_data_for_provider_states() -> None: target_fixture="pact_interaction", ) def an_integration_is_being_defined_for_a_consumer_test() -> ( - Generator[tuple[Pact, HttpInteraction], Any, None] + Generator[PactInteractionTuple[HttpInteraction], Any, None] ): """An integration is being defined for a consumer test.""" pact = Pact("consumer", "provider") pact.with_specification("V3") - yield (pact, pact.upon_receiving("a request")) + 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: tuple[Pact, HttpInteraction], + pact_interaction: PactInteractionTuple[HttpInteraction], state: str, ) -> None: """A provider state is specified.""" - pact_interaction[1].given(state) + pact_interaction.interaction.given(state) @given( @@ -74,7 +74,7 @@ def a_provider_state_is_specified( converters={"table": parse_markdown_table}, ) def a_provider_state_is_specified_with_the_following_data( - pact_interaction: tuple[Pact, HttpInteraction], + pact_interaction: PactInteractionTuple[HttpInteraction], state: str, table: list[dict[str, Any]], ) -> None: @@ -93,7 +93,7 @@ def a_provider_state_is_specified_with_the_following_data( elif value.replace(".", "", 1).isdigit(): row[key] = float(value) - pact_interaction[1].given(state, parameters=table[0]) + pact_interaction.interaction.given(state, parameters=table[0]) ################################################################################ diff --git a/tests/v3/compatibility_suite/test_v4_consumer.py b/tests/v3/compatibility_suite/test_v4_consumer.py index 7b7ab019d..de70ea6f0 100644 --- a/tests/v3/compatibility_suite/test_v4_consumer.py +++ b/tests/v3/compatibility_suite/test_v4_consumer.py @@ -9,7 +9,7 @@ from pytest_bdd import given, parsers, scenario, then from pact.v3.pact import HttpInteraction, Pact -from tests.v3.compatibility_suite.util import string_to_int +from tests.v3.compatibility_suite.util import PactInteractionTuple, string_to_int from tests.v3.compatibility_suite.util.consumer import ( the_pact_file_for_the_test_is_generated, ) @@ -63,41 +63,38 @@ def test_supports_adding_comments() -> None: target_fixture="pact_interaction", ) def an_http_interaction_is_being_defined_for_a_consumer_test() -> ( - Generator[tuple[Pact, HttpInteraction], Any, None] + Generator[PactInteractionTuple[HttpInteraction], Any, None] ): """An HTTP interaction is being defined for a consumer test.""" pact = Pact("consumer", "provider") pact.with_specification("V4") - yield (pact, pact.upon_receiving("a request")) + 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: tuple[Pact, HttpInteraction], + pact_interaction: PactInteractionTuple[HttpInteraction], key: str, ) -> None: """A key is specified for the HTTP interaction.""" - _, interaction = pact_interaction - interaction.set_key(key) + pact_interaction.interaction.set_key(key) @given("the HTTP interaction is marked as pending") def the_http_interaction_is_marked_as_pending( - pact_interaction: tuple[Pact, HttpInteraction], + pact_interaction: PactInteractionTuple[HttpInteraction], ) -> None: """The HTTP interaction is marked as pending.""" - _, interaction = pact_interaction - interaction.set_pending(pending=True) + 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: tuple[Pact, HttpInteraction], + pact_interaction: PactInteractionTuple[HttpInteraction], comment: str, ) -> None: """A comment of "" is added to the HTTP interaction.""" - _, interaction = pact_interaction - interaction.set_comment("text", [comment]) + pact_interaction.interaction.set_comment("text", [comment]) ################################################################################ diff --git a/tests/v3/compatibility_suite/util/__init__.py b/tests/v3/compatibility_suite/util/__init__.py index 1351b1146..236a952a6 100644 --- a/tests/v3/compatibility_suite/util/__init__.py +++ b/tests/v3/compatibility_suite/util/__init__.py @@ -31,7 +31,7 @@ def _(): from collections.abc import Collection, Mapping from datetime import date, datetime, time from pathlib import Path -from typing import Any +from typing import Any, Generic, NamedTuple, TypeVar from xml.etree import ElementTree import flask @@ -47,6 +47,21 @@ def _(): SUITE_ROOT = Path(__file__).parent.parent / "definition" FIXTURES_ROOT = SUITE_ROOT / "fixtures" +_T = TypeVar("_T") + + +class PactInteractionTuple(NamedTuple, 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. + """ + + pact: Pact + interaction: _T + def string_to_int(word: str) -> int: """ diff --git a/tests/v3/compatibility_suite/util/consumer.py b/tests/v3/compatibility_suite/util/consumer.py index f9834187d..e91abc4c6 100644 --- a/tests/v3/compatibility_suite/util/consumer.py +++ b/tests/v3/compatibility_suite/util/consumer.py @@ -17,6 +17,7 @@ from pact.v3 import Pact from tests.v3.compatibility_suite.util import ( FIXTURES_ROOT, + PactInteractionTuple, parse_markdown_table, string_to_int, truncate, @@ -201,10 +202,10 @@ def the_pact_file_for_the_test_is_generated(stacklevel: int = 1) -> None: ) def _( temp_dir: Path, - pact_interaction: tuple[Pact, HttpInteraction], + pact_interaction: PactInteractionTuple[HttpInteraction], ) -> dict[str, Any]: """The Pact file for the test is generated.""" - pact_interaction[0].write_file(temp_dir) + pact_interaction.pact.write_file(temp_dir) with (temp_dir / "consumer-provider.json").open("r") as file: return json.load(file) From cf6cfd7b60f810679d339bbed5af0b1bb3d29fa2 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 10:06:41 +1000 Subject: [PATCH 53/61] refactor(test): v3 message consumer With the other changes in the FFI and Pact Python library, a significant refactor of the tests was introduced. A few steps were combined, though by and large the functionality remains the same. Signed-off-by: JP-Ellis --- .../test_v3_message_consumer.py | 641 ++++++++++-------- 1 file changed, 341 insertions(+), 300 deletions(-) diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py index aec09bc56..97fe1e7c1 100644 --- a/tests/v3/compatibility_suite/test_v3_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -4,9 +4,10 @@ import ast import json +import logging import re from pathlib import Path -from typing import Any, Generator, NamedTuple, Tuple +from typing import Any, List, NamedTuple from pytest_bdd import ( given, @@ -16,70 +17,72 @@ when, ) -from pact.v3.pact import AsyncMessageInteraction, Pact -from tests.v3.compatibility_suite.util import FIXTURES_ROOT, parse_markdown_table - - -class PactInteraction(NamedTuple): - """Holder class for Pact and Interaction.""" - - pact: Pact - interaction: AsyncMessageInteraction - +from pact.v3.pact import AsyncMessageInteraction, InteractionVerificationError, Pact +from tests.v3.compatibility_suite.util import ( + FIXTURES_ROOT, + PactInteractionTuple, + parse_markdown_table, +) -class PactResult(NamedTuple): - """Holder class for Pact Result objects.""" +logger = logging.getLogger(__name__) - received_payload: ReceivedPayload - pact_data: dict[str, Any] | None - error: Exception | None +################################################################################ +## Helpers +################################################################################ -class ReceivedPayload(NamedTuple): +class ReceivedMessage(NamedTuple): """Holder class for Message Received Payload.""" - message: Any + body: Any context: Any -class UnknownTypeError(Exception): - """Unknown type error.""" +class PactResult(NamedTuple): + """Holder class for Pact Result objects.""" - def __init__(self, expected_type: str) -> None: - """Initialize the UnknownTypeError.""" - super().__init__(f"Unknown type: {expected_type}") + messages: List[ReceivedMessage] + pact_data: dict[str, Any] | None + errors: List[InteractionVerificationError] -class UnknownGeneratorCategoryError(Exception): - """Unknown type error.""" +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) - def __init__(self, generator_category: str) -> None: - """Initialize the UnknownGeneratorCategoryError.""" - super().__init__(f"Unknown generator category: {generator_category}") +################################################################################ +## Scenarios +################################################################################ -class TestFailedError(Exception): - """Test failed error.""" - def __init__(self) -> None: - """Initialize the TestFailedError.""" - super().__init__("Test failed") +@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", - "Supports arbitrary message metadata", + "When not all messages are successfully processed", ) -def test_supports_arbitrary_message_metadata() -> None: - """Supports arbitrary message metadata.""" +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 data for provider states", + "Supports arbitrary message metadata", ) -def test_supports_data_for_provider_states() -> None: - """Supports data for provider states.""" +def test_supports_arbitrary_message_metadata() -> None: + """Supports arbitrary message metadata.""" @scenario( @@ -92,10 +95,10 @@ def test_supports_specifying_provider_states() -> None: @scenario( "definition/features/V3/message_consumer.feature", - "Supports the use of generators with message metadata", + "Supports data for provider states", ) -def test_supports_the_use_of_generators_with_message_metadata() -> None: - """Supports the use of generators with message metadata.""" +def test_supports_data_for_provider_states() -> None: + """Supports data for provider states.""" @scenario( @@ -108,18 +111,10 @@ def test_supports_the_use_of_generators_with_the_message_body() -> None: @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", + "Supports the use of generators with message metadata", ) -def test_when_not_all_messages_are_successfully_processed() -> None: - """When not all messages are successfully processed.""" +def test_supports_the_use_of_generators_with_message_metadata() -> None: + """Supports the use of generators with message metadata.""" ################################################################################ @@ -132,55 +127,57 @@ def test_when_not_all_messages_are_successfully_processed() -> None: target_fixture="pact_interaction", ) def a_message_integration_is_being_defined_for_a_consumer_test() -> ( - Generator[tuple[Pact, AsyncMessageInteraction], Any, None] + PactInteractionTuple[AsyncMessageInteraction] ): """A message integration is being defined for a consumer test.""" pact = Pact("consumer", "provider") pact.with_specification("V3") - yield PactInteraction(pact, pact.upon_receiving("a request", "Async")) - - -@given("a message is defined") -def _a_message_is_defined() -> None: - """A message is defined.""" + return PactInteractionTuple( + pact, + pact.upon_receiving("an asynchronous message", "Async"), + ) @given( parsers.re( - r'a provider state "(?P[^"]+)" for the message ' - r"is specified with the following data:\n(?P
.+)", + r'a provider state "(?P[^"]+)" for the message is specified' + r"( with the following data:\n)?(?P
.*)", re.DOTALL, ), - converters={"table": parse_markdown_table}, + converters={"table": lambda v: parse_markdown_table(v) if v else None}, ) def a_provider_state_for_the_message_is_specified_with_the_following_data( - pact_interaction: PactInteraction, state: str, table: list[dict[str, Any]] + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + state: str, + table: list[dict[str, str]] | None, ) -> None: """A provider state for the message is specified with the following data.""" - for parameters in table: - state_params = {k: ast.literal_eval(v) for k, v in parameters.items()} - pact_interaction.interaction.given(state, parameters=state_params) + logger.debug("Specifying provider state '%s': %s", state, table) + if table: + parameters = {k: ast.literal_eval(v) for k, v in table[0].items()} + pact_interaction.interaction.given(state, parameters=parameters) + else: + pact_interaction.interaction.given(state) -@given(parsers.re(r'a provider state "(?P[^"]+)" for the message is specified')) -def a_provider_state_for_the_message_is_specified( - pact_interaction: PactInteraction, - state: str, -) -> None: - """A provider state for the message is specified.""" - pact_interaction.interaction.given(state) +@given("a message is defined") +def a_message_is_defined() -> None: + """A message is defined.""" @given( parsers.re( - "the message contains the following " "metadata:\n(?P
.+)", re.DOTALL + r"the message contains the following metadata:\n(?P
.+)", + re.DOTALL, ), converters={"table": parse_markdown_table}, ) def the_message_contains_the_following_metadata( - pact_interaction: PactInteraction, table: list[dict[str, Any]] + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + table: list[dict[str, Any]], ) -> None: """The message contains the following metadata.""" + logger.debug("Adding metadata to message: %s", table) for metadata in table: if metadata.get("value", "").startswith("JSON: "): metadata["value"] = metadata["value"].replace("JSON:", "") @@ -189,40 +186,63 @@ def the_message_contains_the_following_metadata( @given( parsers.re( - "the message is configured with the following:\n" "(?P
.+)", re.DOTALL + r"the message is configured with the following:\n(?P
.+)", + re.DOTALL, ), converters={"table": parse_markdown_table}, ) def the_message_is_configured_with_the_following( - pact_interaction: PactInteraction, + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], table: list[dict[str, Any]], ) -> None: """The message is configured with the following.""" - body_json, generator_json, metadata_json = _build_message_data(table) - if generator_json: - category = next(iter(generator_json.keys())) - if category == "body": - _build_body_generator(generator_json, body_json) - elif category == "metadata": - _build_metadata_generator(generator_json, metadata_json) + 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: - raise UnknownGeneratorCategoryError(category) - pact_interaction.interaction.with_body(json.dumps(body_json)) - for k, v in metadata_json.items(): - v_str = v - if isinstance(v, dict): - v_str = json.dumps(v) - pact_interaction.interaction.with_metadata({k: str(v_str)}) + file = FIXTURES_ROOT / generators + pact_interaction.interaction.with_generators(file.read_text()) + + if metadata := config.pop("metadata", None): + data: dict[str, Any] = 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('the message payload contains the "(?P[^"]+)" JSON document') + parsers.re(r'the message payload contains the "(?P[^"]+)" JSON document') ) def the_message_payload_contains_the_basic_json_document( - pact_interaction: PactInteraction, json_doc: str + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + basename: str, ) -> None: """The message payload contains the "basic" JSON document.""" - pact_interaction.interaction.with_body(json.dumps(read_json(f"{json_doc}.json"))) + 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", + ) ################################################################################ @@ -230,46 +250,58 @@ def the_message_payload_contains_the_basic_json_document( ################################################################################ -@when( - 'the message is NOT successfully processed with a "Test failed" exception', - target_fixture="pact_result", -) -def the_message_is_not_successfully_processed_with_an_exception( - pact_interaction: PactInteraction, -) -> PactResult: - """The message is NOT successfully processed with a "Test failed" exception.""" - # using a dict here because it's mutable - received_payload: dict[str, ReceivedPayload] = {} - - def fail(async_message: str | dict[Any, Any], context: dict[Any, Any]) -> None: - received_payload["data"] = ReceivedPayload(async_message, context) - raise TestFailedError - - try: - pact_interaction.interaction.verify(fail) - return PactResult(received_payload["data"], None, None) - except Exception as e: # noqa: BLE001 - return PactResult(received_payload["data"], None, e) - - @when("the message is successfully processed", target_fixture="pact_result") def the_message_is_successfully_processed( - pact_interaction: PactInteraction, temp_dir: Path -) -> Generator[PactResult, Any, None]: + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + temp_dir: Path, +) -> PactResult: """The message is successfully processed.""" - received_payload: dict[str, ReceivedPayload] = {} + messages: list[ReceivedMessage] = [] def handler( - async_message: str | dict[Any, Any], - context: dict[Any, Any], + body: str | bytes | None, + context: dict[str, str], ) -> None: - received_payload["data"] = ReceivedPayload(async_message, context) + 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 occured during verification:", len(errors)) + for error in errors: + logger.error(error) + msg = "Errors occurred during verification" + raise AssertionError(msg) - pact_interaction.interaction.verify(handler) (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) pact_interaction.pact.write_file(temp_dir / "pacts") with (temp_dir / "pacts" / "consumer-provider.json").open() as file: - yield PactResult(received_payload.get("data"), json.load(file), None) + pact_data = json.load(file) + + return 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, str]) -> None: + messages.append(ReceivedMessage(body, context)) + raise AssertionError(failure) + + errors = pact_interaction.pact.verify(handler, "Async", raises=False) + return PactResult(messages, None, errors) ################################################################################ @@ -277,20 +309,19 @@ def handler( ################################################################################ -@then("a Pact file for the message interaction will NOT have been written") -def a_pact_file_for_the_message_interaction_will_not_have_been_written( - temp_dir: Path, -) -> None: - """A Pact file for the message interaction will NOT have been written.""" - assert not Path(temp_dir / "pacts" / "consumer-provider.json").exists() - - -@then("a Pact file for the message interaction will have been written") -def a_pact_file_for_the_message_interaction_will_have_been_written( +@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( temp_dir: Path, + success: bool, # noqa: FBT001 ) -> None: - """A Pact file for the message interaction will have been written.""" - assert Path(temp_dir / "pacts" / "consumer-provider.json").exists() + """A Pact file for the message interaction will maybe have been written.""" + assert Path(temp_dir / "pacts" / "consumer-provider.json").exists() == success @then(parsers.re(r'the consumer test error will be "(?P[^"]+)"')) @@ -299,60 +330,33 @@ def the_consumer_test_error_will_be_test_failed( error: str, ) -> None: """The consumer test error will be "Test failed".""" - assert str(pact_result.error) == error - - -@then("the consumer test will have failed") -def the_consumer_test_will_have_failed(pact_result: PactResult) -> None: - """The consumer test will have failed.""" - assert type(pact_result.error) == TestFailedError - assert pact_result.pact_data is None - - -@then("the consumer test will have passed") -def the_consumer_test_will_have_passed(pact_result: PactResult) -> None: - """The consumer test will have passed.""" - assert pact_result.error is None - assert pact_result.pact_data is not None + assert len(pact_result.errors) == 1 + assert error in str(pact_result.errors[0].error) @then( - parsers.re( - r"the first message in the Pact file will contain " - 'provider state "(?P[^"]+)"' - ) + parsers.re(r"the consumer test will have (?Ppassed|failed)"), + converters={"success": lambda x: x == "passed"}, ) -def the_first_message_in_the_pact_file_will_contain_provider_state( +def the_consumer_test_will_have_passed_or_failed( pact_result: PactResult, - state: str, + success: bool, # noqa: FBT001 ) -> 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) - found = False - for provider_state in messages[0]["providerStates"]: - if state in list(provider_state.values()): - found = True - break - assert found + """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 " - 'will be "(?P[^"]+)"' + 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 "application/json".""" + """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) @@ -366,7 +370,7 @@ def the_first_message_in_the_pact_file_content_type_will_be( @then( parsers.re( r"the first message in the pact file will contain " - "(?P[0-9]+) provider states?" + r"(?P\d+) provider states?" ), converters={"state_count": int}, ) @@ -387,14 +391,47 @@ def the_first_message_in_the_pact_file_will_contain( @then( parsers.re( - "the first message in the pact file will contain " - 'the "(?P[^"]+)" document' + 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, json_doc: str + 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) @@ -402,7 +439,12 @@ def the_first_message_in_the_pact_file_will_contain_the_basic_json_document( if not isinstance(messages, list) or not messages: msg = "No messages found" raise RuntimeError(msg) - assert messages[0]["contents"] == read_json(json_doc) + 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( @@ -430,19 +472,25 @@ def the_first_message_in_the_pact_file_will_contain_the_message_metadata( @then( parsers.re( - r'the message contents for "(?P[^"]+)" ' - 'will have been replaced with an? "(?P[^"]+)"' + 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, - replace_token: str, + path: str, expected_type: str, ) -> None: """The message contents for "$.one" will have been replaced with an "integer".""" - elem_key = replace_token.split(".")[1] - value = json.loads(pact_result.received_payload.message).get(elem_key) - assert compare_type(expected_type, value) + 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( @@ -454,7 +502,7 @@ def the_pact_file_will_contain_message_interaction( pact_result: PactResult, interaction_count: int, ) -> None: - """The pact file will contain 1 message interaction.""" + """The pact file will contain N message interaction.""" if not pact_result.pact_data: msg = "No pact data found" raise RuntimeError(msg) @@ -465,30 +513,61 @@ def the_pact_file_will_contain_message_interaction( @then( parsers.re( r'the provider state "(?P[^"]+)" for the message ' - r"will contain the following parameters:\n(?P.+)", + r"will contain the following parameters:\n(?P
.+)", re.DOTALL, ), - converters={"parameters": parse_markdown_table}, + converters={"table": parse_markdown_table}, ) def the_provider_state_for_the_message_will_contain_the_following_parameters( - pact_interaction: PactInteraction, + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + pact_result: PactResult, state: str, - parameters: list[dict[str, Any]], + table: list[dict[str, Any]], ) -> None: """The provider state for the message will contain the following parameters.""" - provider_state_params = None - expected_params = json.loads(parameters[0]["parameters"]) - for provider_state in pact_interaction.pact.get_provider_states(): + assert len(table) == 1, "Only one row is expected" + expected = json.loads(table[0]["parameters"]) + + # 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"] + for provider_state in provider_states: if provider_state["name"] == state: - provider_state_params = provider_state["params"] + assert expected == provider_state["params"] break - # if we have provider_state_params, we found the expected provider state name - assert provider_state_params is not None - found = {k: False for k in expected_params} - for k, v in expected_params.items(): - assert ast.literal_eval(provider_state_params.get(k)) == v - found[k] = True - assert all(found.values()) + 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: {provider_state}" + raise ValueError(msg) + break + else: + msg = "No interactions found" + raise ValueError(msg) @then( @@ -499,131 +578,93 @@ def the_received_message_content_type_will_be( content_type: str, ) -> None: """The received message content type will be "application/json".""" - assert pact_result.received_payload.context.get("contentType") == content_type + 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 "(?P[^"]+)" ' - 'replaced with an? "(?P[^"]+)"' + r"the received message metadata will contain " + r'"(?P[^"]+)" == "(?P[^"\\]*(?:\\.[^"\\]*)*)"' ) ) -def the_received_message_metadata_will_contain_replaced_with( +def the_received_message_metadata_will_contain( pact_result: PactResult, key: str, - expected_type: str, + value: Any, # noqa: ANN401 ) -> None: - """The received message metadata will contain "ID" replaced with an "integer".""" - found = False - metadata = pact_result.received_payload.context - if metadata.get(key): - assert compare_type(expected_type, metadata[key]) - found = True - assert found + """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 json.loads(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 " - r'"(?P[^"]+)" == "(?P[^"\\]*(?:\\.[^"\\]*)*)"' + r'the received message metadata will contain "(?P[^"]+)" ' + r'replaced with an? "(?P[^"]+)"' ) ) -def the_received_message_metadata_will_contain( +def the_received_message_metadata_will_contain_replaced_with( pact_result: PactResult, key: str, - value: Any, # noqa: ANN401 + expected_type: str, ) -> None: - """The received message metadata will contain "Origin" == "Some Text".""" - if value.startswith("JSON: "): - value = value.replace("JSON: ", "") - value = value.replace('\\"', '"') - value = json.loads(value) - metadata = pact_result.received_payload.context - - found = False - if metadata.get(key): - assert metadata[key] == value - found = True - assert found + """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 " - 'the "(?P[^"]+)" JSON document' + r'the "(?P[^"]+)" JSON document' ) ) def the_received_message_payload_will_contain_the_basic_json_document( - pact_result: PactResult, json_doc: str + pact_result: PactResult, + basename: str, ) -> None: - """The received message payload will contain the "basic" JSON document.""" - assert json.loads(pact_result.received_payload.message) == read_json( - f"{json_doc}.json" - ) - - -def read_json(file: str) -> dict[str, Any]: - with Path(FIXTURES_ROOT / file).open() as f: - return json.loads(f.read()) - - -def compare_type(expected_type: str, t: str | int | None) -> bool: - if expected_type == "integer": - assert t is not None - try: - int(t) - except ValueError: - return False - return True - raise UnknownTypeError(expected_type) + """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] -def _build_message_data( - table: list[dict[str, Any]], -) -> Tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: - body_json = generator_json = metadata_json = {} - for entry in table: - for k, v in entry.items(): - if k == "generators": - if v.startswith("JSON: "): - generator_json = json.loads(v.replace("JSON:", "")) - else: - generator_json = read_json(v) - elif k == "body": - if v.startswith("file: "): - file = v.replace("file: ", "") - body_json = read_json(file) - elif k == "metadata": - metadata_json = json.loads(v) - return body_json, generator_json, metadata_json - - -def _build_body_generator( - generator_json: dict[str, Any], body_json: dict[str, Any] -) -> None: - for k, v in generator_json["body"].items(): - elem_name = k.split(".")[1] - body_elem = body_json.get(elem_name) - replace_value = { - "pact:generator:type": v["type"], - "pact:matcher:type": "notEmpty", - "value": body_elem, - } - body_json.update({elem_name: replace_value}) - - -def _build_metadata_generator( - generator_json: dict[str, Any], metadata_json: dict[str, Any] -) -> None: - for k in generator_json["metadata"]: - metadata = metadata_json[k] - if not isinstance(metadata, dict): - metadata = {"value": metadata} - metadata_json[k] = metadata - generator_data = generator_json["metadata"][k] - metadata.update({ - "pact:generator:type": generator_data["type"], - "pact:matcher:type": "notEmpty", - }) - del generator_data["type"] - metadata.update(generator_data) + 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 From a9fa8f0884aa53df49047f1628c13c571a837e9c Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 13:50:35 +1000 Subject: [PATCH 54/61] refactor(examples): v3 message consumer Signed-off-by: JP-Ellis --- examples/tests/v3/test_01_message_consumer.py | 120 ++++++++++-------- 1 file changed, 66 insertions(+), 54 deletions(-) diff --git a/examples/tests/v3/test_01_message_consumer.py b/examples/tests/v3/test_01_message_consumer.py index b7fe1368e..e0def9a9c 100644 --- a/examples/tests/v3/test_01_message_consumer.py +++ b/examples/tests/v3/test_01_message_consumer.py @@ -73,86 +73,98 @@ def pact() -> Generator[Pact, None, None]: @pytest.fixture() -def handler() -> tuple[Handler, Callable[[Dict[str, Any], Dict[str, Any]], str | None]]: +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 - # need a function to accept the params - # the pact will send in during verify - # and call the actual function under test - def pact_handler(msg: Dict[str, Any], context: Dict[str, Any]) -> str | None: + +@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={"processed_message": msg, "context": context}, + extra={"input": msg, "processed_message": data, "context": context}, ) - return handler.process(msg) + handler.process(data) + + yield _verifier - log.info("Handler created") - return handler, pact_handler + assert handler.fs.mock_calls, "Handler did not call the filesystem" def test_async_message_handler_write( pact: Pact, - handler: tuple[ - Handler, - Callable[ - [Dict[str, Any], Dict[str, Any]], - str | None, - ], - ], + handler: Handler, + verifier: Callable[[str | bytes | None, Dict[str, Any]], None], ) -> None: """ Create a pact between the message handler and the message provider. """ - actual_handler, pact_handler = handler - actual_handler.fs.write.return_value = None # type: ignore[attr-defined] - async_message = { - "action": "WRITE", - "path": "my_file.txt", - "contents": "Hello, world!", - } - processed_message = ( + 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(async_message)) - .verify(pact_handler) - ) - actual_handler.fs.write.assert_called_once_with( # type: ignore[attr-defined] - async_message["path"], - async_message["contents"], + .with_body( + json.dumps({ + "action": "WRITE", + "path": "my_file.txt", + "contents": "Hello, world!", + }) + ) ) - assert processed_message is not None - assert processed_message.response is None + 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: tuple[ - Handler, - Callable[ - [Dict[str, Any], Dict[str, Any]], - str | None, - ], - ], + handler: Handler, + verifier: Callable[[str | bytes | None, Dict[str, Any]], None], ) -> None: """ Create a pact between the message handler and the message provider. """ - actual_handler, pact_handler = handler - async_message = { - "action": "READ", - "path": "my_file.txt", - "contents": "Hello, world!", - } - actual_handler.fs.read.return_value = async_message["contents"] # type: ignore[attr-defined] - processed_message = ( + 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(async_message)) - .verify(pact_handler) - ) - actual_handler.fs.read.assert_called_once_with( # type: ignore[attr-defined] - "my_file.txt", + .with_body( + json.dumps({ + "action": "READ", + "path": "my_file.txt", + "contents": "Hello, world!", + }) + ) ) - assert processed_message is not None - assert processed_message.response == async_message["contents"] + pact.verify(verifier, "Async") + + handler.fs.read.assert_called_once_with("my_file.txt") From 67f53226c7b8b50e25e4b134ceaa13e11bdd02c1 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 13:51:05 +1000 Subject: [PATCH 55/61] fix(v3): typing issues Signed-off-by: JP-Ellis --- src/pact/v3/pact.py | 10 +-------- .../test_v3_message_consumer.py | 10 ++++----- tests/v3/compatibility_suite/util/__init__.py | 21 +++++++++++++++---- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/pact/v3/pact.py b/src/pact/v3/pact.py index 4c2df73ef..8b2f26d9c 100644 --- a/src/pact/v3/pact.py +++ b/src/pact/v3/pact.py @@ -64,7 +64,6 @@ import json import logging -import typing import warnings from pathlib import Path from typing import ( @@ -550,14 +549,7 @@ def verify( """ errors: List[InteractionVerificationError] = [] for message in self.interactions(kind): - if TYPE_CHECKING: - # This is required to ensure that the type checker knows what - # type `message` is. - message = typing.cast( - pact.v3.ffi.SynchronousMessage | pact.v3.ffi.AsynchronousMessage, - message, - ) - + request: pact.v3.ffi.MessageContents | None = None if isinstance(message, pact.v3.ffi.SynchronousMessage): request = message.request_contents elif isinstance(message, pact.v3.ffi.AsynchronousMessage): diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py index 97fe1e7c1..2286654b7 100644 --- a/tests/v3/compatibility_suite/test_v3_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -217,7 +217,7 @@ def the_message_is_configured_with_the_following( pact_interaction.interaction.with_generators(file.read_text()) if metadata := config.pop("metadata", None): - data: dict[str, Any] = json.loads(metadata) + data = json.loads(metadata) pact_interaction.interaction.with_metadata({ k: json.dumps(v) for k, v in data.items() }) @@ -543,9 +543,9 @@ def the_provider_state_for_the_message_will_contain_the_following_parameters( assert len(message["providerStates"]) > 0, "At least one provider state is expected" provider_states = message["providerStates"] - for provider_state in provider_states: - if provider_state["name"] == state: - assert expected == provider_state["params"] + 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}" @@ -562,7 +562,7 @@ def the_provider_state_for_the_message_will_contain_the_following_parameters( assert expected == provider_state_params break else: - msg = f"Provider state not found: {provider_state}" + msg = f"Provider state not found: {state}" raise ValueError(msg) break else: diff --git a/tests/v3/compatibility_suite/util/__init__.py b/tests/v3/compatibility_suite/util/__init__.py index 236a952a6..91b5871d1 100644 --- a/tests/v3/compatibility_suite/util/__init__.py +++ b/tests/v3/compatibility_suite/util/__init__.py @@ -31,7 +31,7 @@ def _(): from collections.abc import Collection, Mapping from datetime import date, datetime, time from pathlib import Path -from typing import Any, Generic, NamedTuple, TypeVar +from typing import Any, Generic, TypeVar from xml.etree import ElementTree import flask @@ -50,17 +50,30 @@ def _(): _T = TypeVar("_T") -class PactInteractionTuple(NamedTuple, Generic[_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). """ - pact: Pact - interaction: _T + def __init__(self, pact: Pact, interaction: _T) -> None: + """ + Instantiate the tuple. + """ + self.pact = pact + self.interaction = interaction def string_to_int(word: str) -> int: From 654c904cc63c27f317dcd6430c81365304fa27d9 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 15:49:44 +1000 Subject: [PATCH 56/61] feat(ffi): upgrade ffi library to v0.4.21 Signed-off-by: JP-Ellis --- hatch_build.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hatch_build.py b/hatch_build.py index 9842fb6a4..ab5e84e0f 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -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}" @@ -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: @@ -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( From 999fb4521d304565460b4b7215d90a033f07c5b7 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 15:52:12 +1000 Subject: [PATCH 57/61] ci: add wheel target Now that the FFI library supports Windows ARM, we can add it to the list. Also a minor update to make use of the ARM runners when building the macOS ARM wheel. Signed-off-by: JP-Ellis --- .github/workflows/build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a3c15ea6f..258d8ad7b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 From aed2e09b9c894edcae7f9140d7b193ade6e411aa Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 19 Jun 2024 16:48:25 +1000 Subject: [PATCH 58/61] chore(v3): remove defunct test Signed-off-by: JP-Ellis --- tests/v3/test_pact.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/v3/test_pact.py b/tests/v3/test_pact.py index 5f32e7d80..2264346d0 100644 --- a/tests/v3/test_pact.py +++ b/tests/v3/test_pact.py @@ -93,16 +93,6 @@ def test_interactions_iter( raise RuntimeError(msg) -def test_messages(pact: Pact) -> None: - messages = pact.messages() - assert messages is not None - for _message in messages: - # This should be an empty list and therefore the error should never be - # raised. - msg = "Should not be reached" - raise RuntimeError(msg) - - def test_write_file(pact: Pact, temp_dir: Path) -> None: pact.write_file(temp_dir) outfile = temp_dir / "consumer-provider.json" From 31b9ed40f7e61fea0613c0de861f8a5ea14dee52 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 20 Jun 2024 10:27:31 +1000 Subject: [PATCH 59/61] refactor(tests): minor drying message consumer tests Signed-off-by: JP-Ellis --- .../test_v3_message_consumer.py | 29 ++++---- .../test_v4_message_consumer.py | 66 +++++++------------ tests/v3/compatibility_suite/util/consumer.py | 36 +++++++++- 3 files changed, 67 insertions(+), 64 deletions(-) diff --git a/tests/v3/compatibility_suite/test_v3_message_consumer.py b/tests/v3/compatibility_suite/test_v3_message_consumer.py index 2286654b7..bdb038368 100644 --- a/tests/v3/compatibility_suite/test_v3_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v3_message_consumer.py @@ -6,8 +6,7 @@ import json import logging import re -from pathlib import Path -from typing import Any, List, NamedTuple +from typing import TYPE_CHECKING, Any, List, NamedTuple from pytest_bdd import ( given, @@ -17,12 +16,19 @@ when, ) -from pact.v3.pact import AsyncMessageInteraction, InteractionVerificationError, Pact from tests.v3.compatibility_suite.util import ( FIXTURES_ROOT, PactInteractionTuple, parse_markdown_table, ) +from tests.v3.compatibility_suite.util.consumer import ( + a_message_integration_is_being_defined_for_a_consumer_test, +) + +if TYPE_CHECKING: + from pathlib import Path + + from pact.v3.pact import AsyncMessageInteraction, InteractionVerificationError logger = logging.getLogger(__name__) @@ -122,20 +128,7 @@ def test_supports_the_use_of_generators_with_message_metadata() -> None: ################################################################################ -@given( - "a message integration is being defined for a consumer test", - target_fixture="pact_interaction", -) -def a_message_integration_is_being_defined_for_a_consumer_test() -> ( - PactInteractionTuple[AsyncMessageInteraction] -): - """A message integration is being defined for a consumer test.""" - pact = Pact("consumer", "provider") - pact.with_specification("V3") - return PactInteractionTuple( - pact, - pact.upon_receiving("an asynchronous message", "Async"), - ) +a_message_integration_is_being_defined_for_a_consumer_test("V3") @given( @@ -321,7 +314,7 @@ def a_pact_file_for_the_message_interaction_will_maybe_have_been_written( success: bool, # noqa: FBT001 ) -> None: """A Pact file for the message interaction will maybe have been written.""" - assert Path(temp_dir / "pacts" / "consumer-provider.json").exists() == success + assert (temp_dir / "pacts" / "consumer-provider.json").exists() == success @then(parsers.re(r'the consumer test error will be "(?P[^"]+)"')) diff --git a/tests/v3/compatibility_suite/test_v4_message_consumer.py b/tests/v3/compatibility_suite/test_v4_message_consumer.py index d50a13717..5b1332165 100644 --- a/tests/v3/compatibility_suite/test_v4_message_consumer.py +++ b/tests/v3/compatibility_suite/test_v4_message_consumer.py @@ -3,28 +3,23 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING, Any, Generator, NamedTuple +from typing import TYPE_CHECKING, Any from pytest_bdd import ( given, parsers, scenario, then, - when, ) -from pact.v3.pact import AsyncMessageInteraction, Pact -from tests.v3.compatibility_suite.util import string_to_int +from tests.v3.compatibility_suite.util import PactInteractionTuple, string_to_int +from tests.v3.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 pathlib import Path - - -class PactInteraction(NamedTuple): - """Holder class for Pact and Interaction.""" - - pact: Pact - interaction: AsyncMessageInteraction + from pact.v3.pact import AsyncMessageInteraction @scenario( @@ -35,11 +30,6 @@ def test_sets_the_type_for_the_interaction() -> None: """Sets the type for the interaction.""" -@scenario("definition/features/V4/message_consumer.feature", "Supports adding comments") -def test_supports_adding_comments() -> None: - """Supports adding comments.""" - - @scenario( "definition/features/V4/message_consumer.feature", "Supports specifying a key for the interaction", @@ -56,16 +46,27 @@ 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: PactInteraction, comment: str + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], + comment: str, ) -> None: """A comment "{comment}" is added to the message interaction.""" pact_interaction.interaction.add_text_comment(comment) @@ -74,30 +75,17 @@ def a_comment_is_added_to_the_message_interaction( @given( parsers.re(r'a key of "(?P[^"]+)" is specified for the message interaction') ) -def a_key_is_specified_for_the_http_interaction( - pact_interaction: PactInteraction, +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( - "a message interaction is being defined for a consumer test", - target_fixture="pact_interaction", -) -def a_message_interaction_is_being_defined_for_a_consumer_test() -> ( - Generator[PactInteraction, Any, None] -): - """A message integration is being defined for a consumer test.""" - pact = Pact("consumer", "provider") - pact.with_specification("V4") - yield PactInteraction(pact, pact.upon_receiving("a request", "Async")) - - @given("the message interaction is marked as pending") def the_message_interaction_is_marked_as_pending( - pact_interaction: PactInteraction, + pact_interaction: PactInteractionTuple[AsyncMessageInteraction], ) -> None: """The message interaction is marked as pending.""" pact_interaction.interaction.set_pending(pending=True) @@ -108,15 +96,7 @@ def the_message_interaction_is_marked_as_pending( ################################################################################ -@when("the Pact file for the test is generated", target_fixture="pact_data") -def the_pact_file_for_the_test_is_generated( - pact_interaction: PactInteraction, temp_dir: Path -) -> Generator[Any, Any, None]: - """The Pact file for the test is generated.""" - (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) - pact_interaction.pact.write_file(temp_dir / "pacts") - with (temp_dir / "pacts" / "consumer-provider.json").open() as file: - yield json.load(file) +the_pact_file_for_the_test_is_generated() ################################################################################ diff --git a/tests/v3/compatibility_suite/util/consumer.py b/tests/v3/compatibility_suite/util/consumer.py index e91abc4c6..38bf7676e 100644 --- a/tests/v3/compatibility_suite/util/consumer.py +++ b/tests/v3/compatibility_suite/util/consumer.py @@ -11,7 +11,7 @@ import pytest import requests -from pytest_bdd import parsers, then, when +from pytest_bdd import given, parsers, then, when from yarl import URL from pact.v3 import Pact @@ -27,11 +27,41 @@ from collections.abc import Generator from pathlib import Path - from pact.v3.pact import HttpInteraction, PactServer + from pact.v3.interaction._async_message_interaction import AsyncMessageInteraction + from pact.v3.pact import PactServer from tests.v3.compatibility_suite.util import InteractionDefinition logger = logging.getLogger(__name__) +################################################################################ +## 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. + """ + pact = Pact("consumer", "provider") + pact.with_specification(version) + return PactInteractionTuple( + pact, + pact.upon_receiving("an asynchronous message", "Async"), + ) + + ################################################################################ ## When ################################################################################ @@ -202,7 +232,7 @@ def the_pact_file_for_the_test_is_generated(stacklevel: int = 1) -> None: ) def _( temp_dir: Path, - pact_interaction: PactInteractionTuple[HttpInteraction], + pact_interaction: PactInteractionTuple[Any], ) -> dict[str, Any]: """The Pact file for the test is generated.""" pact_interaction.pact.write_file(temp_dir) From dd14a47c42bc8adbfa713ce3e079fcaf82387c7d Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 21 Jun 2024 11:03:56 +1000 Subject: [PATCH 60/61] chore: ignore test outputs Signed-off-by: JP-Ellis --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 763596319..5ed2af480 100644 --- a/.gitignore +++ b/.gitignore @@ -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 From 9e210f67e35c62840ce880efe97c10d573df23d7 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 21 Jun 2024 11:04:18 +1000 Subject: [PATCH 61/61] docs: fix discovery Signed-off-by: JP-Ellis --- examples/tests/v3/__init__.py | 0 examples/tests/v3/test_01_message_consumer.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 examples/tests/v3/__init__.py diff --git a/examples/tests/v3/__init__.py b/examples/tests/v3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tests/v3/test_01_message_consumer.py b/examples/tests/v3/test_01_message_consumer.py index e0def9a9c..9016be9b8 100644 --- a/examples/tests/v3/test_01_message_consumer.py +++ b/examples/tests/v3/test_01_message_consumer.py @@ -19,8 +19,8 @@ from unittest.mock import MagicMock import pytest -from examples.src.message import Handler +from examples.src.message import Handler from pact.v3.pact import Pact if TYPE_CHECKING: