From 00d6fbbc9fb78d63cc814fda9677e6e71b6219db Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 5 Mar 2024 10:05:58 +1100 Subject: [PATCH] feat(v3): add verifier class Signed-off-by: JP-Ellis --- src/pact/v3/__init__.py | 2 + src/pact/v3/ffi.py | 552 +++++++++++++++---------- src/pact/v3/verifier.py | 881 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1229 insertions(+), 206 deletions(-) create mode 100644 src/pact/v3/verifier.py diff --git a/src/pact/v3/__init__.py b/src/pact/v3/__init__.py index 20701bd7f..826a0d790 100644 --- a/src/pact/v3/__init__.py +++ b/src/pact/v3/__init__.py @@ -23,9 +23,11 @@ import warnings from pact.v3.pact import Pact +from pact.v3.verifier import Verifier __all__ = [ "Pact", + "Verifier", ] warnings.warn( diff --git a/src/pact/v3/ffi.py b/src/pact/v3/ffi.py index c6893c754..e809e173d 100644 --- a/src/pact/v3/ffi.py +++ b/src/pact/v3/ffi.py @@ -83,6 +83,7 @@ import gc import json +import logging import typing import warnings from enum import Enum @@ -91,11 +92,15 @@ from pact.v3._ffi import ffi, lib # type: ignore[import] if TYPE_CHECKING: + import datetime + from collections.abc import Collection from pathlib import Path import cffi from typing_extensions import Self +logger = logging.getLogger(__name__) + # 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. @@ -535,7 +540,7 @@ class VerifierHandle: [Rust `VerifierHandle`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/verifier/handle/struct.VerifierHandle.html) """ - def __init__(self, ref: int) -> None: + def __init__(self, ref: cffi.FFI.CData) -> None: """ Initialise a new Verifier Handle. @@ -543,7 +548,7 @@ def __init__(self, ref: int) -> None: ref: Rust library reference to the Verifier Handle. """ - self._ref: int = ref + self._ref = ref def __del__(self) -> None: """ @@ -555,13 +560,13 @@ def __str__(self) -> str: """ String representation of the Verifier Handle. """ - return f"PactHandle({self._ref})" + return f"VerifierHandle({hex(id(self._ref))})" def __repr__(self) -> str: """ String representation of the Verifier Handle. """ - return f"PactHandle({self._ref!r})" + return f"" class ExpressionValueType(Enum): @@ -6370,7 +6375,7 @@ def verifier_new_for_application() -> VerifierHandle: """ from pact import __version__ - result: int = lib.pactffi_verifier_new_for_application( + result: cffi.FFI.CData = lib.pactffi_verifier_new_for_application( b"pact-python", __version__.encode("utf-8"), ) @@ -6388,61 +6393,98 @@ def verifier_shutdown(handle: VerifierHandle) -> None: def verifier_set_provider_info( # noqa: PLR0913 handle: VerifierHandle, - name: str, - scheme: str, - host: str, - port: int, - path: str, + name: str | None, + scheme: str | None, + host: str | None, + port: int | None, + path: str | None, ) -> None: """ Set the provider details for the Pact verifier. - Passing a NULL for any field will use the default value for that field. - [Rust `pactffi_verifier_set_provider_info`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_set_provider_info) - # Safety + Args: + handle: + The verifier handle to update. - All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced - with U+FFFD REPLACEMENT CHARACTER. + name: + A user-friendly name to describe the provider. + + scheme: + Determine the scheme to use, typically one of `HTTP` or `HTTPS`. + + host: + The host of the provider. This may be either a hostname to resolve, + or an IP address. + + port: + The port of the provider. + + path: + The path of the provider. + + If any value is `None`, the default value as determined by the underlying + FFI library will be used. """ - raise NotImplementedError + lib.pactffi_verifier_set_provider_info( + handle._ref, + name.encode("utf-8") if name else ffi.NULL, + scheme.encode("utf-8") if scheme else ffi.NULL, + host.encode("utf-8") if host else ffi.NULL, + port, + path.encode("utf-8") if path else ffi.NULL, + ) def verifier_add_provider_transport( handle: VerifierHandle, - protocol: str, + protocol: str | None, port: int, - path: str, - scheme: str, + path: str | None, + scheme: str | None, ) -> None: """ Adds a new transport for the given provider. - Passing a NULL for any field will use the default value for that field. - [Rust `pactffi_verifier_add_provider_transport`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_add_provider_transport) - For non-plugin based message interactions, set protocol to "message" and set - scheme to an empty string or "https" if secure HTTP is required. - Communication to the calling application will be over HTTP to the default - provider hostname. + Args: + handle: + The verifier handle to update. - # Safety + protocol: + In this context, the kind of - All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced - with U+FFFD REPLACEMENT CHARACTER. + port: + The port of the provider. + + path: + The path of the provider. + + scheme: + The scheme to use, typically one of `HTTP` or `HTTPS`. + + If any value is `None`, the default value as determined by the underlying + FFI library will be used. """ - raise NotImplementedError + lib.pactffi_verifier_add_provider_transport( + handle._ref, + protocol.encode("utf-8") if protocol else ffi.NULL, + port, + path.encode("utf-8") if path else ffi.NULL, + scheme.encode("utf-8") if scheme else ffi.NULL, + ) def verifier_set_filter_info( handle: VerifierHandle, - filter_description: str, - filter_state: str, - filter_no_state: int, + filter_description: str | None, + filter_state: str | None, + *, + filter_no_state: bool, ) -> None: """ Set the filters for the Pact verifier. @@ -6450,26 +6492,35 @@ def verifier_set_filter_info( [Rust `pactffi_verifier_set_filter_info`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_set_filter_info) - If `filter_description` is not empty, it needs to be as a regular - expression. + Set filters to narrow down the interactions to verify. - `filter_no_state` is a boolean value. Set it to greater than zero to turn - the option on. + Args: + handle: + The verifier handle to update. - # Safety + filter_description: + A regular expression to filter the interactions by description. - All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced - with U+FFFD REPLACEMENT CHARACTER. + filter_state: + A regular expression to filter the interactions by state. + filter_no_state: + If `True`, the option to filter by state will be turned on. """ - raise NotImplementedError + lib.pactffi_verifier_set_filter_info( + handle._ref, + filter_description.encode("utf-8") if filter_description else ffi.NULL, + filter_state.encode("utf-8") if filter_state else ffi.NULL, + filter_no_state, + ) def verifier_set_provider_state( handle: VerifierHandle, url: str, - teardown: int, - body: int, + *, + teardown: bool, + body: bool, ) -> None: """ Set the provider state URL for the Pact verifier. @@ -6477,134 +6528,170 @@ def verifier_set_provider_state( [Rust `pactffi_verifier_set_provider_state`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_set_provider_state) - `teardown` is a boolean value. If teardown state change requests should be - made after an interaction is validated (default is false). Set it to greater - than zero to turn the option on. `body` is a boolean value. Sets if state - change request data should be sent in the body (> 0, true) or as query - parameters (== 0, false). Set it to greater than zero to turn the option on. + Args: + handle: + The verifier handle to update. - # Safety + url: + The URL to use for the provider state. - All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced - with U+FFFD REPLACEMENT CHARACTER. + teardown: + If teardown state change requests should be made after an + interaction is validated. - """ - raise NotImplementedError + body: + If state change request data should be sent in the body or the + query. + """ + lib.pactffi_verifier_set_provider_state( + handle._ref, + url.encode("utf-8"), + teardown, + body, + ) def verifier_set_verification_options( handle: VerifierHandle, - disable_ssl_verification: int, + *, + disable_ssl_verification: bool, request_timeout: int, -) -> int: +) -> None: """ Set the options used by the verifier when calling the provider. [Rust `pactffi_verifier_set_verification_options`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_set_verification_options) - `disable_ssl_verification` is a boolean value. Set it to greater than zero - to turn the option on. - - # Safety + Args: + handle: + The verifier handle to update. - All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced - with U+FFFD REPLACEMENT CHARACTER. + disable_ssl_verification: + If SSL verification should be disabled. + request_timeout: + The timeout for the request in milliseconds. """ - raise NotImplementedError + retval: int = lib.pactffi_verifier_set_verification_options( + handle._ref, + disable_ssl_verification, + request_timeout, + ) + if retval != 0: + msg = f"Failed to set verification options for {handle}." + raise RuntimeError(msg) -def verifier_set_coloured_output(handle: VerifierHandle, coloured_output: int) -> int: +def verifier_set_coloured_output( + handle: VerifierHandle, + *, + enabled: bool, +) -> None: """ Enables or disables coloured output using ANSI escape codes. - By default, coloured output is enabled. - [Rust `pactffi_verifier_set_coloured_output`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_set_coloured_output) - `coloured_output` is a boolean value. Set it to greater than zero to turn - the option on. - - # Safety + By default, coloured output is enabled. - This function is safe as long as the handle pointer points to a valid - handle. + Args: + handle: + The verifier handle to update. + enabled: + A boolean value to enable or disable coloured output. """ - raise NotImplementedError + retval: int = lib.pactffi_verifier_set_coloured_output( + handle._ref, + enabled, + ) + if retval != 0: + msg = f"Failed to set coloured output for {handle}." + raise RuntimeError(msg) -def verifier_set_no_pacts_is_error(handle: VerifierHandle, is_error: int) -> int: +def verifier_set_no_pacts_is_error(handle: VerifierHandle, *, enabled: bool) -> None: """ Enables or disables if no pacts are found to verify results in an error. [Rust `pactffi_verifier_set_no_pacts_is_error`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_set_no_pacts_is_error) - `is_error` is a boolean value. Set it to greater than zero to enable an - error when no pacts are found to verify, and set it to zero to disable this. - - # Safety - - This function is safe as long as the handle pointer points to a valid - handle. + Args: + handle: + The verifier handle to update. + enabled: + If `True`, an error will be raised when no pacts are found to verify. """ - raise NotImplementedError + retval: int = lib.pactffi_verifier_set_no_pacts_is_error( + handle._ref, + enabled, + ) + if retval != 0: + msg = f"Failed to set no pacts is error for {handle}." + raise RuntimeError(msg) -def verifier_set_publish_options( # noqa: PLR0913 +def verifier_set_publish_options( handle: VerifierHandle, provider_version: str, build_url: str, provider_tags: List[str], - provider_tags_len: int, provider_branch: str, -) -> int: +) -> None: """ Set the options used when publishing verification results to the Broker. [Rust `pactffi_verifier_set_publish_options`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_set_publish_options) - # Args + Args: + handle: + The verifier handle to update. - - `handle` - The pact verifier handle to update - - `provider_version` - Version of the provider to publish - - `build_url` - URL to the build which ran the verification - - `provider_tags` - Collection of tags for the provider - - `provider_tags_len` - Number of provider tags supplied - - `provider_branch` - Name of the branch used for verification + provider_version: + Version of the provider to publish. - # Safety + build_url: + URL to the build which ran the verification. - All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced - with U+FFFD REPLACEMENT CHARACTER. + provider_tags: + Collection of tags for the provider. + provider_branch: + Name of the branch used for verification. """ - raise NotImplementedError + retval: int = lib.pactffi_verifier_set_publish_options( + handle._ref, + provider_version.encode("utf-8"), + build_url.encode("utf-8"), + [ffi.new("char[]", t.encode("utf-8")) for t in provider_tags or []], + len(provider_tags), + provider_branch.encode("utf-8"), + ) + if retval != 0: + msg = f"Failed to set publish options for {handle}." + raise RuntimeError(msg) def verifier_set_consumer_filters( handle: VerifierHandle, - consumer_filters: List[str], - consumer_filters_len: int, + consumer_filters: Collection[str], ) -> None: """ Set the consumer filters for the Pact verifier. [Rust `pactffi_verifier_set_consumer_filters`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_set_consumer_filters) - - # Safety - - All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced - with U+FFFD REPLACEMENT CHARACTER. - """ - raise NotImplementedError + lib.pactffi_verifier_set_consumer_filters( + handle._ref, + [ffi.new("char[]", f.encode("utf-8")) for f in consumer_filters], + len(consumer_filters), + ) def verifier_add_custom_header( @@ -6617,13 +6704,12 @@ def verifier_add_custom_header( [Rust `pactffi_verifier_add_custom_header`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_add_custom_header) - - # Safety - - The header name and value must point to a valid NULL terminated string and - must contain valid UTF-8. """ - raise NotImplementedError + lib.pactffi_verifier_add_custom_header( + handle._ref, + header_name.encode("utf-8"), + header_value.encode("utf-8"), + ) def verifier_add_file_source(handle: VerifierHandle, file: str) -> None: @@ -6632,14 +6718,8 @@ def verifier_add_file_source(handle: VerifierHandle, file: str) -> None: [Rust `pactffi_verifier_add_file_source`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_add_file_source) - - # Safety - - All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced - with U+FFFD REPLACEMENT CHARACTER. - """ - raise NotImplementedError + lib.pactffi_verifier_add_file_source(handle._ref, file.encode("utf-8")) def verifier_add_directory_source(handle: VerifierHandle, directory: str) -> None: @@ -6657,124 +6737,179 @@ def verifier_add_directory_source(handle: VerifierHandle, directory: str) -> Non with U+FFFD REPLACEMENT CHARACTER. """ - raise NotImplementedError + lib.pactffi_verifier_add_directory_source(handle._ref, directory.encode("utf-8")) def verifier_url_source( handle: VerifierHandle, url: str, - username: str, - password: str, - token: str, + username: str | None, + password: str | None, + token: str | None, ) -> None: """ Adds a URL as a source to verify. - The Pact file will be fetched from the URL. - [Rust `pactffi_verifier_url_source`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_url_source) - If a username and password is given, then basic authentication will be used - when fetching the pact file. If a token is provided, then bearer token - authentication will be used. + Args: + handle: + The verifier handle to update. - # Safety + url: + The URL to use as a source for the verifier. - All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced - with U+FFFD REPLACEMENT CHARACTER. + username: + The username to use when fetching pacts from the URL. + password: + The password to use when fetching pacts from the URL. + + token: + The token to use when fetching pacts from the URL. This will be used + as a bearer token. It is mutually exclusive with the username and + password. """ - raise NotImplementedError + lib.pactffi_verifier_url_source( + handle._ref, + url.encode("utf-8"), + username.encode("utf-8") if username else ffi.NULL, + password.encode("utf-8") if password else ffi.NULL, + token.encode("utf-8") if token else ffi.NULL, + ) def verifier_broker_source( handle: VerifierHandle, url: str, - username: str, - password: str, - token: str, + username: str | None, + password: str | None, + token: str | None, ) -> None: """ Adds a Pact broker as a source to verify. + [Rust + `pactffi_verifier_broker_source`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_broker_source) + This will fetch all the pact files from the broker that match the provider name. - [Rust - `pactffi_verifier_broker_source`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_broker_source) + Args: + handle: + The verifier handle to update. - If a username and password is given, then basic authentication will be used - when fetching the pact file. If a token is provided, then bearer token - authentication will be used. + url: + The URL to use as a source for the verifier. - # Safety + username: + The username to use when fetching pacts from the broker. - All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced - with U+FFFD REPLACEMENT CHARACTER. + password: + The password to use when fetching pacts from the broker. + token: + The token to use when fetching pacts from the broker. This will be + used as a bearer token. """ - raise NotImplementedError + lib.pactffi_verifier_broker_source( + handle._ref, + url.encode("utf-8"), + username.encode("utf-8") if username else ffi.NULL, + password.encode("utf-8") if password else ffi.NULL, + token.encode("utf-8") if token else ffi.NULL, + ) def verifier_broker_source_with_selectors( # noqa: PLR0913 handle: VerifierHandle, url: str, - username: str, - password: str, - token: str, + username: str | None, + password: str | None, + token: str | None, enable_pending: int, - include_wip_pacts_since: str, + include_wip_pacts_since: datetime.date | None, provider_tags: List[str], - provider_tags_len: int, - provider_branch: str, + provider_branch: str | None, consumer_version_selectors: List[str], - consumer_version_selectors_len: int, consumer_version_tags: List[str], - consumer_version_tags_len: int, ) -> None: """ Adds a Pact broker as a source to verify. - This will fetch all the pact files from the broker that match the provider - name and the consumer version selectors (See - `https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/`). - [Rust `pactffi_verifier_broker_source_with_selectors`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_broker_source_with_selectors) - The consumer version selectors must be passed in in JSON format. - - `enable_pending` is a boolean value. Set it to greater than zero to turn the - option on. - - If the `include_wip_pacts_since` option is provided, it needs to be a date - formatted in ISO format (YYYY-MM-DD). - - If a username and password is given, then basic authentication will be used - when fetching the pact file. If a token is provided, then bearer token - authentication will be used. - - # Safety - - All string fields must contain valid UTF-8. Invalid UTF-8 will be replaced - with U+FFFD REPLACEMENT CHARACTER. + This will fetch all the pact files from the broker that match the provider + name and the consumer version selectors (See [Consumer Version + Selectors](https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/)). - """ - raise NotImplementedError + Args: + handle: + The verifier handle to update. + + url: + The URL to use as a source for the verifier. + + username: + The username to use when fetching pacts from the broker. + + password: + The password to use when fetching pacts from the broker. + + token: + The token to use when fetching pacts from the broker. This will be + used as a bearer token. + + enable_pending: + If pending pacts should be included in the verification process. + + include_wip_pacts_since: + The date to use to filter out WIP pacts. + + provider_tags: + The tags to use to filter the provider pacts. + + provider_branch: + The branch to use to filter the provider pacts. + + consumer_version_selectors: + The consumer version selectors to use to filter the consumer pacts. + + consumer_version_tags: + The tags to use to filter the consumer pacts. + """ + lib.pactffi_verifier_broker_source_with_selectors( + handle._ref, + url.encode("utf-8"), + username.encode("utf-8") if username else ffi.NULL, + password.encode("utf-8") if password else ffi.NULL, + token.encode("utf-8") if token else ffi.NULL, + enable_pending, + include_wip_pacts_since.isoformat().encode("utf-8") + if include_wip_pacts_since + else ffi.NULL, + [ffi.new("char[]", t.encode("utf-8")) for t in provider_tags], + len(provider_tags), + provider_branch.encode("utf-8") if provider_branch else ffi.NULL, + [ffi.new("char[]", s.encode("utf-8")) for s in consumer_version_selectors], + len(consumer_version_selectors), + [ffi.new("char[]", t.encode("utf-8")) for t in consumer_version_tags], + len(consumer_version_tags), + ) -def verifier_execute(handle: VerifierHandle) -> int: +def verifier_execute(handle: VerifierHandle) -> None: """ Runs the verification. - [Rust `pactffi_verifier_execute`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_execute) - - # Error Handling - - Errors will be reported with a non-zero return value. + (https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_execute) """ - raise NotImplementedError + success: int = lib.pactffi_verifier_execute(handle._ref) + if success != 0: + msg = f"Failed to execute verifier for {handle}." + raise RuntimeError(msg) def verifier_cli_args() -> str: @@ -6840,68 +6975,73 @@ def verifier_logs(handle: VerifierHandle) -> OwnedString: """ Extracts the logs for the verification run. - This needs the memory buffer log sink to be setup before the verification is - executed. The returned string will need to be freed with the `free_string` - function call to avoid leaking memory. - [Rust `pactffi_verifier_logs`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_logs) - Will return a NULL pointer if the logs for the verification can not be - retrieved. + This needs the memory buffer log sink to be setup before the verification is + executed. The returned string will need to be freed with the `free_string` + function call to avoid leaking memory. """ - raise NotImplementedError + ptr = lib.pactffi_verifier_logs(handle._ref) + if ptr == ffi.NULL: + msg = f"Failed to get logs for {handle}." + raise RuntimeError(msg) + return OwnedString(ptr) def verifier_logs_for_provider(provider_name: str) -> OwnedString: """ Extracts the logs for the verification run for the provider name. - This needs the memory buffer log sink to be setup before the verification is - executed. The returned string will need to be freed with the `free_string` - function call to avoid leaking memory. - [Rust `pactffi_verifier_logs_for_provider`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_logs_for_provider) - Will return a NULL pointer if the logs for the verification can not be - retrieved. + This needs the memory buffer log sink to be setup before the verification is + executed. The returned string will need to be freed with the `free_string` + function call to avoid leaking memory. """ - raise NotImplementedError + ptr = lib.pactffi_verifier_logs_for_provider(provider_name.encode("utf-8")) + if ptr == ffi.NULL: + msg = f"Failed to get logs for {provider_name}." + raise RuntimeError(msg) + return OwnedString(ptr) def verifier_output(handle: VerifierHandle, strip_ansi: int) -> OwnedString: """ Extracts the standard output for the verification run. - The returned string will need to be freed with the `free_string` function - call to avoid leaking memory. - [Rust `pactffi_verifier_output`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_output) - * `strip_ansi` - This parameter controls ANSI escape codes. Setting it to a - non-zero value - will cause the ANSI control codes to be stripped from the output. + Args: + handle: + The verifier handle to update. - Will return a NULL pointer if the handle is invalid. + strip_ansi: + This parameter controls ANSI escape codes. Setting it to a non-zero + value will cause the ANSI control codes to be stripped from the + output. """ - raise NotImplementedError + ptr = lib.pactffi_verifier_output(handle._ref, strip_ansi) + if ptr == ffi.NULL: + msg = f"Failed to get output for {handle}." + raise RuntimeError(msg) + return OwnedString(ptr) def verifier_json(handle: VerifierHandle) -> OwnedString: """ Extracts the verification result as a JSON document. - The returned string will need to be freed with the `free_string` function - call to avoid leaking memory. - [Rust `pactffi_verifier_json`](https://docs.rs/pact_ffi/0.4.18/pact_ffi/?search=pactffi_verifier_json) - - Will return a NULL pointer if the handle is invalid. """ - raise NotImplementedError + ptr = lib.pactffi_verifier_json(handle._ref) + if ptr == ffi.NULL: + msg = f"Failed to get JSON for {handle}." + raise RuntimeError(msg) + return OwnedString(ptr) def using_plugin( diff --git a/src/pact/v3/verifier.py b/src/pact/v3/verifier.py new file mode 100644 index 000000000..c6cf8f436 --- /dev/null +++ b/src/pact/v3/verifier.py @@ -0,0 +1,881 @@ +""" +Verifier for Pact. + +The Verifier is used to verify that a provider meets the expectations of a +consumer. This is done by replaying interactions from the consumer against the +provider, and ensuring that the provider's responses match the expectations set +by the consumer. +""" + +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, overload + +from typing_extensions import Self +from yarl import URL + +import pact.v3.ffi + +if TYPE_CHECKING: + from collections.abc import Iterable + + +class Verifier: + """ + A Verifier between a consumer and a provider. + + This class encapsulates the logic for verifying that a provider meets the + expectations of a consumer. This is done by replaying interactions from the + consumer against the provider, and ensuring that the provider's responses + match the expectations set by the consumer. + """ + + def __init__(self) -> None: + """ + Create a new Verifier. + """ + self._handle: pact.v3.ffi.VerifierHandle = ( + pact.v3.ffi.verifier_new_for_application() + ) + + # In order to provide a fluent interface, we remember some options which + # are set using the same FFI method. + self._disable_ssl_verification = False + self._request_timeout = 5000 + + def __str__(self) -> str: + """ + Informal string representation of the Verifier. + """ + return "Verifier" + + def __repr__(self) -> str: + """ + Information-rish string representation of the Verifier. + """ + return f"" + + def set_info( # noqa: PLR0913 + self, + name: str, + *, + url: str | URL | None = None, + scheme: str | None = None, + host: str | None = None, + port: int | None = None, + path: str | None = None, + ) -> Self: + """ + Set the provider information. + + This sets up information about the provider as well as the way it + communicates with the consumer. Note that for historical reasons, a + HTTP(S) transport method is always added. + + For a provider which uses other protocols (such as message queues), the + [`add_provider_transport`][pact.v3.verifier.Verifier.add_provider_transport] + must be used. This method can be called multiple times to add multiple + transport methods. + + Args: + name: + A user-friendly name for the provider. + + url: + The URL on which requests are made to the provider by Pact. + + It is recommended to use this parameter to set the provider URL. + If the port is not explicitly set, the default port for the + scheme will be used. + + This parameter is mutually exclusive with the individual + parameters. + + scheme: + The provider scheme. This must be one of `http` or `https`. + + host: + The provider hostname or IP address. If the provider is running + on the same machine as the verifier, `localhost` can be used. + + port: + The provider port. If not specified, the default port for the + schema will be used. + + path: + The provider context path. If not specified, the root path will + be used. + + If a non-root path is used, the path given here will be + prepended to the path in the interaction. For example, if the + path is `/api`, and the interaction path is `/users`, the + request will be made to `/api/users`. + """ + if url is not None: + if any(param is not None for param in (scheme, host, port, path)): + msg = "Cannot specify both `url` and individual parameters" + raise ValueError(msg) + + url = URL(url) + scheme = url.scheme + host = url.host + port = url.explicit_port + path = url.path + + if port is None: + msg = "Unable to determine default port for scheme {scheme}" + raise ValueError(msg) + + pact.v3.ffi.verifier_set_provider_info( + self._handle, + name, + scheme, + host, + port, + path, + ) + return self + + url = URL.build( + scheme=scheme or "http", + host=host or "localhost", + port=port, + path=path or "", + ) + return self.set_info(name, url=url) + + def add_transport( + self, + *, + protocol: str, + port: int | None = None, + path: str | None = None, + scheme: str | None = None, + ) -> Self: + """ + Add a provider transport method. + + If the provider supports multiple transport methods, or non-HTTP(S) + methods, this method allows these additional transport methods to be + added. It can be called multiple times to add multiple transport methods. + + As some transport methods may not use ports, paths or schemes, these + parameters are optional. + + Args: + protocol: + The protocol to use. This will typically be one of: + + - `http` for communications over HTTP(S). Note that when + setting up the provider information in + [`set_provider_info`][pact.v3.verifier.Verifier.set_provider_info], + a HTTP transport method is always added and it is unlikely + that an additional HTTP transport method will be needed + unless the provider is running on additional ports. + + - `message` for non-plugin synchronous message-based + communications. + + Any other protocol will be treated as a custom protocol and will + be handled by a plugin. + + port: + The provider port. + + If the protocol does not use ports, this parameter should be + `None`. If not specified, the default port for the scheme will + be used (provided the scheme is known). + + path: + The provider context path. + + For protocols which do not use paths, this parameter should be + `None`. + + For protocols which do use paths, this parameter should be + specified to avoid any ambiguity, though if left unspecified, + the root path will be used. + + If a non-root path is used, the path given here will be + prepended to the path in the interaction. For example, if the + path is `/api`, and the interaction path is `/users`, the + request will be made to `/api/users`. + + scheme: + The provider scheme, if applicable to the protocol. + + This is typically only used for the `http` protocol, where this + value can either be `http` (the default) or `https`. + """ + if port is None and scheme: + if scheme.lower() == "http": + port = 80 + elif scheme.lower() == "https": + port = 443 + + pact.v3.ffi.verifier_add_provider_transport( + self._handle, + protocol, + port or 0, + path, + scheme, + ) + return self + + def filter( + self, + description: str | None = None, + *, + state: str | None = None, + no_state: bool = False, + ) -> Self: + """ + Set the filter for the interactions. + + This method can be used to filter interactions based on their + description and state. Repeated calls to this method will replace the + previous filter. + + Args: + description: + The interaction description. This should be a regular + expression. If unspecified, no filtering will be done based on + the description. + + state: + The interaction state. This should be a regular expression. If + unspecified, no filtering will be done based on the state. + + no_state: + Whether to include interactions with no state. + """ + pact.v3.ffi.verifier_set_filter_info( + self._handle, + description, + state, + filter_no_state=no_state, + ) + return self + + def set_state( + self, + url: str | URL, + *, + teardown: bool = False, + body: bool = False, + ) -> Self: + """ + Set the provider state URL. + + The URL is used when the provider's internal state needs to be changed. + For example, a consumer might have an interaction that requires a + specific user to be present in the database. The provider state URL is + used to change the provider's internal state to include the required + user. + + Args: + url: + The URL to which a `POST` request will be made to change the + provider's internal state. + + teardown: + Whether to teardown the provider state after an interaction is + validated. + + body: + Whether to include the state change request in the body (`True`) + or in the query string (`False`). + """ + pact.v3.ffi.verifier_set_provider_state( + self._handle, + url if isinstance(url, str) else str(url), + teardown=teardown, + body=body, + ) + return self + + def disable_ssl_verification(self) -> Self: + """ + Disable SSL verification. + """ + self._disable_ssl_verification = True + pact.v3.ffi.verifier_set_verification_options( + self._handle, + disable_ssl_verification=self._disable_ssl_verification, + request_timeout=self._request_timeout, + ) + return self + + def set_request_timeout(self, timeout: int) -> Self: + """ + Set the request timeout. + + Args: + timeout: + The request timeout in milliseconds. + """ + if timeout < 0: + msg = "Request timeout must be a positive integer" + raise ValueError(msg) + + self._request_timeout = timeout + pact.v3.ffi.verifier_set_verification_options( + self._handle, + disable_ssl_verification=self._disable_ssl_verification, + request_timeout=self._request_timeout, + ) + return self + + def set_coloured_output(self, *, enabled: bool = True) -> Self: + """ + Toggle coloured output. + """ + pact.v3.ffi.verifier_set_coloured_output(self._handle, enabled=enabled) + return self + + def set_error_on_empty_pact(self, *, enabled: bool = True) -> Self: + """ + Toggle error on empty pact. + + If enabled, a Pact file with no interactions will cause the verifier to + return an error. If disabled, a Pact file with no interactions will be + ignored. + """ + pact.v3.ffi.verifier_set_no_pacts_is_error(self._handle, enabled=enabled) + return self + + def set_publish_options( + self, + version: str, + url: str, + branch: str, + tags: list[str] | None = None, + ) -> Self: + """ + Set options used when publishing results to the Broker. + + Args: + version: + The provider version. + + url: + URL to the build which ran the verification. + + tags: + Collection of tags for the provider. + + branch: + Name of the branch used for verification. + """ + pact.v3.ffi.verifier_set_publish_options( + self._handle, + version, + url, + tags or [], + branch, + ) + return self + + def filter_consumers(self, *filters: str) -> Self: + """ + Filter the consumers. + + Args: + filters: + Filters to apply to the consumers. + """ + pact.v3.ffi.verifier_set_consumer_filters(self._handle, filters) + return self + + def add_custom_header(self, name: str, value: str) -> Self: + """ + Add a customer header to the request. + + These headers are added to every request made to the provider. + + Args: + name: + The key of the header. + + value: + The value of the header. + """ + pact.v3.ffi.verifier_add_custom_header(self._handle, name, value) + return self + + def add_custom_headers( + self, + headers: dict[str, str] | Iterable[tuple[str, str]], + ) -> Self: + """ + Add multiple customer headers to the request. + + These headers are added to every request made to the provider. + + Args: + headers: + The headers to add. This can be a dictionary or an iterable of + key-value pairs. The iterable is preferred as it ensures that + repeated headers are not lost. + """ + if isinstance(headers, dict): + headers = headers.items() + for name, value in headers: + self.add_custom_header(name, value) + return self + + @overload + def add_source( + self, + source: str | URL, + *, + username: str | None = None, + password: str | None = None, + ) -> Self: ... + + @overload + def add_source(self, source: str | URL, *, token: str | None = None) -> Self: ... + + @overload + def add_source(self, source: str | Path) -> Self: ... + + def add_source( + self, + source: str | Path | URL, + *, + username: str | None = None, + password: str | None = None, + token: str | None = None, + ) -> Self: + """ + Adds a source to the verifier. + + This will use one or more Pact files as the source of interactions to + verify. + + Args: + source: + The source of the interactions. This may be either of the + following: + + - A local file path to a Pact file. + - A local file path to a directory containing Pact files. + - A URL to a Pact file. + + If using a URL, the `username` and `password` parameters can be + used to provide basic HTTP authentication, or the `token` + parameter can be used to provide bearer token authentication. + The `username` and `password` parameters can also be passed as + part of the URL. + + username: + The username to use for basic HTTP authentication. This is only + used when the source is a URL. + + password: + The password to use for basic HTTP authentication. This is only + used when the source is a URL. + + token: + The token to use for bearer token authentication. This is only + used when the source is a URL. Note that this is mutually + exclusive with `username` and `password`. + """ + if isinstance(source, Path): + return self._add_source_local(source) + + if isinstance(source, URL): + if source.scheme == "file": + return self._add_source_local(source.path) + + if source.scheme in ("http", "https"): + return self._add_source_remote( + source, + username=username, + password=password, + token=token, + ) + + msg = f"Invalid source scheme: {source.scheme}" + raise ValueError(msg) + + # Strings are ambiguous, so we need identify them as either local or + # remote. + if "://" in source: + return self._add_source_remote( + URL(source), + username=username, + password=password, + token=token, + ) + return self._add_source_local(source) + + def _add_source_local(self, source: str | Path) -> Self: + """ + Adds a local source to the verifier. + + This will use one or more Pact files as the source of interactions to + verify. + + Args: + source: + The source of the interactions. This may be either of the + following: + + - A local file path to a Pact file. + - A local file path to a directory containing Pact files. + """ + source = Path(source) + if source.is_dir(): + pact.v3.ffi.verifier_add_directory_source(self._handle, str(source)) + return self + if source.is_file(): + pact.v3.ffi.verifier_add_file_source(self._handle, str(source)) + return self + msg = f"Invalid source: {source}" + raise ValueError(msg) + + def _add_source_remote( + self, + url: str | URL, + *, + username: str | None = None, + password: str | None = None, + token: str | None = None, + ) -> Self: + """ + Add a remote source to the verifier. + + This will use a Pact file accessible over HTTP or HTTPS as the source of + interactions to verify. + + Args: + url: + The source of the interactions. This must be a URL to a Pact + file. The URL may contain a username and password for basic HTTP + authentication. + + username: + The username to use for basic HTTP authentication. If the source + is a URL containing a username, this parameter must be `None`. + + password: + The password to use for basic HTTP authentication. If the source + is a URL containing a password, this parameter must be `None`. + + token: + The token to use for bearer token authentication. This is + mutually exclusive with `username` and `password` (whether they + be specified through arguments, or embedded in the URL). + """ + url = URL(url) + + if url.user and username: + msg = "Cannot specify both `username` and a username in the URL" + raise ValueError(msg) + username = url.user or username + + if url.password and password: + msg = "Cannot specify both `password` and a password in the URL" + raise ValueError(msg) + password = url.password or password + + if token and (username or password): + msg = "Cannot specify both `token` and `username`/`password`" + raise ValueError(msg) + + pact.v3.ffi.verifier_url_source( + self._handle, + str(url), + username, + password, + token, + ) + return self + + @overload + def broker_source( + self, + url: str | URL, + *, + username: str | None = None, + password: str | None = None, + selector: Literal[False] = False, + ) -> Self: ... + + @overload + def broker_source( + self, + url: str | URL, + *, + token: str | None = None, + selector: Literal[False] = False, + ) -> Self: ... + + @overload + def broker_source( + self, + url: str | URL, + *, + username: str | None = None, + password: str | None = None, + selector: Literal[True], + ) -> BrokerSelectorBuilder: ... + + @overload + def broker_source( + self, + url: str | URL, + *, + token: str | None = None, + selector: Literal[True], + ) -> BrokerSelectorBuilder: ... + + def broker_source( # noqa: PLR0913 + self, + url: str | URL, + *, + username: str | None = None, + password: str | None = None, + token: str | None = None, + selector: bool = False, + ) -> BrokerSelectorBuilder | Self: + """ + Adds a broker source to the verifier. + + Args: + url: + The broker URL. TThe URL may contain a username and password for + basic HTTP authentication. + + username: + The username to use for basic HTTP authentication. If the source + is a URL containing a username, this parameter must be `None`. + + password: + The password to use for basic HTTP authentication. If the source + is a URL containing a password, this parameter must be `None`. + + token: + The token to use for bearer token authentication. This is + mutually exclusive with `username` and `password` (whether they + be specified through arguments, or embedded in the URL). + + selector: + Whether to return a BrokerSelectorBuilder instance. + """ + url = URL(url) + + if url.user and username: + msg = "Cannot specify both `username` and a username in the URL" + raise ValueError(msg) + username = url.user or username + + if url.password and password: + msg = "Cannot specify both `password` and a password in the URL" + raise ValueError(msg) + password = url.password or password + + if token and (username or password): + msg = "Cannot specify both `token` and `username`/`password`" + raise ValueError(msg) + + if selector: + return BrokerSelectorBuilder( + self, + str(url), + username, + password, + token, + ) + pact.v3.ffi.verifier_broker_source( + self._handle, + str(url), + username, + password, + token, + ) + return self + + def verify(self) -> Self: + """ + Verify the interactions. + + Returns: + Whether the interactions were verified successfully. + """ + pact.v3.ffi.verifier_execute(self._handle) + return self + + @property + def logs(self) -> str: + """ + Get the logs. + """ + return pact.v3.ffi.verifier_logs(self._handle) + + @classmethod + def logs_for_provider(cls, provider: str) -> str: + """ + Get the logs for a provider. + """ + return pact.v3.ffi.verifier_logs_for_provider(provider) + + def output(self, *, strip_ansi: bool = False) -> str: + """ + Get the output. + """ + return pact.v3.ffi.verifier_output(self._handle, strip_ansi=strip_ansi) + + @property + def results(self) -> dict[str, Any]: + """ + Get the results. + """ + return json.loads(pact.v3.ffi.verifier_json(self._handle)) + + +class BrokerSelectorBuilder: + """ + A Broker selector. + + This class encapsulates the logic for selecting Pacts from a Pact broker. + """ + + def __init__( # noqa: PLR0913 + self, + verifier: Verifier, + url: str, + username: str | None, + password: str | None, + token: str | None, + ) -> None: + """ + Instantiate a new Broker Selector. + + This constructor should not be called directly. Instead, use the + `broker_source` method of the `Verifier` class with `selector=True`. + """ + self._verifier = verifier + self._url = url + self._username = username + self._password = password + self._token = token + + # If the instance is dropped without having the `build()` method called, + # raise a warning. + self._built = False + + self._include_pending: bool = False + "Whether to include pending Pacts." + + self._include_wip_since: date | None = None + "Whether to include work in progress Pacts since a given date." + + self._provider_tags: list[str] | None = None + "List of provider tags to match." + + self._provider_branch: str | None = None + "The provider branch." + + self._consumer_versions: list[str] | None = None + "List of consumer version regex patterns." + + self._consumer_tags: list[str] | None = None + "List of consumer tags to match." + + def include_pending(self) -> Self: + """ + Include pending Pacts. + """ + self._include_pending = True + return self + + def exclude_pending(self) -> Self: + """ + Exclude pending Pacts. + """ + self._include_pending = False + return self + + def include_wip_since(self, d: str | date) -> Self: + """ + Include work in progress Pacts since a given date. + """ + if isinstance(d, str): + d = date.fromisoformat(d) + self._include_wip_since = d + return self + + def exclude_wip(self) -> Self: + """ + Exclude work in progress Pacts. + """ + self._include_wip_since = None + return self + + def provider_tags(self, *tags: str) -> Self: + """ + Set the provider tags. + """ + self._provider_tags = list(tags) + return self + + def provider_branch(self, branch: str) -> Self: + """ + Set the provider branch. + """ + self._provider_branch = branch + return self + + def consumer_versions(self, *versions: str) -> Self: + """ + Set the consumer versions. + """ + self._consumer_versions = list(versions) + return self + + def consumer_tags(self, *tags: str) -> Self: + """ + Set the consumer tags. + """ + self._consumer_tags = list(tags) + return self + + def build(self) -> Verifier: + """ + Build the Broker Selector. + + Returns: + The Verifier instance with the broker source added. + """ + pact.v3.ffi.verifier_broker_source_with_selectors( + self._verifier._handle, # noqa: SLF001 + self._url, + self._username, + self._password, + self._token, + self._include_pending, + self._include_wip_since, + self._provider_tags or [], + self._provider_branch, + self._consumer_versions or [], + self._consumer_tags or [], + ) + self._built = True + return self._verifier + + def __del__(self) -> None: + """ + Destructor for the Broker Selector. + + This destructor will raise a warning if the instance is dropped without + having the [`build()`][pact.v3.verifier.BrokerSelectorBuilder.build] + method called. + """ + if not self._built: + msg = "BrokerSelectorBuilder was dropped before being built." + raise Warning(msg)