From 5ef141ed7d0d72027db693a1368b936fb6d322b4 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 30 Sep 2025 08:57:11 +1000 Subject: [PATCH 1/8] chore!: rename abstract matcher class Make clear it is abstract by naming it `AbstractMatcher`. BREAKING CHANGE: The abstract `pact.match.Matcher` class has been renamed to `pact.match.AbstractMatcher`. Signed-off-by: JP-Ellis --- src/pact/interaction/_base.py | 8 +-- src/pact/interaction/_http_interaction.py | 14 ++--- src/pact/match/__init__.py | 74 ++++++++++++----------- src/pact/match/matcher.py | 26 ++++---- 4 files changed, 64 insertions(+), 58 deletions(-) diff --git a/src/pact/interaction/_base.py b/src/pact/interaction/_base.py index aaeea5be1..dfaa7a483 100644 --- a/src/pact/interaction/_base.py +++ b/src/pact/interaction/_base.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: from pathlib import Path - from pact.match import Matcher + from pact.match import AbstractMatcher try: from typing import Self @@ -189,7 +189,7 @@ def given( def with_body( self, - body: str | dict[str, Any] | Matcher[Any] | None = None, + body: str | dict[str, Any] | AbstractMatcher[Any] | None = None, content_type: str | None = None, part: Literal["Request", "Response"] | None = None, ) -> Self: @@ -259,10 +259,10 @@ def with_binary_body( def with_metadata( self, - metadata: dict[str, object | Matcher[object]] | None = None, + metadata: dict[str, object | AbstractMatcher[object]] | None = None, part: Literal["Request", "Response"] | None = None, /, - **kwargs: object | Matcher[object], + **kwargs: object | AbstractMatcher[object], ) -> Self: """ Add metadata for the interaction. diff --git a/src/pact/interaction/_http_interaction.py b/src/pact/interaction/_http_interaction.py index d9342d12f..5c9496c4f 100644 --- a/src/pact/interaction/_http_interaction.py +++ b/src/pact/interaction/_http_interaction.py @@ -11,7 +11,7 @@ import pact_ffi from pact.interaction._base import Interaction -from pact.match import Matcher +from pact.match import AbstractMatcher from pact.match.matcher import IntegrationJSONEncoder if TYPE_CHECKING: @@ -100,7 +100,7 @@ def _interaction_part(self) -> pact_ffi.InteractionPart: """ return self.__interaction_part - def with_request(self, method: str, path: str | Matcher[object]) -> Self: + def with_request(self, method: str, path: str | AbstractMatcher[object]) -> Self: """ Set the request. @@ -113,7 +113,7 @@ def with_request(self, method: str, path: str | Matcher[object]) -> Self: path: Path for the request. """ - if isinstance(path, Matcher): + if isinstance(path, AbstractMatcher): path_str = json.dumps(path, cls=IntegrationJSONEncoder) else: path_str = path @@ -123,7 +123,7 @@ def with_request(self, method: str, path: str | Matcher[object]) -> Self: def with_header( self, name: str, - value: str | dict[str, str] | Matcher[object], + value: str | dict[str, str] | AbstractMatcher[object], part: Literal["Request", "Response"] | None = None, ) -> Self: r""" @@ -318,7 +318,7 @@ def set_headers( def with_query_parameter( self, name: str, - value: object | Matcher[object], + value: object | AbstractMatcher[object], ) -> Self: r""" Add a query to the request. @@ -359,8 +359,8 @@ def with_query_parameter( def with_query_parameters( self, - parameters: Mapping[str, object | Matcher[object]] - | Iterable[tuple[str, object | Matcher[object]]], + parameters: Mapping[str, object | AbstractMatcher[object]] + | Iterable[tuple[str, object | AbstractMatcher[object]]], ) -> Self: """ Add multiple query parameters to the request. diff --git a/src/pact/match/__init__.py b/src/pact/match/__init__.py index f103fe21f..43a6050a6 100644 --- a/src/pact/match/__init__.py +++ b/src/pact/match/__init__.py @@ -53,11 +53,11 @@ from pact import generate from pact._util import strftime_to_simple_date_format from pact.match.matcher import ( + AbstractMatcher, ArrayContainsMatcher, EachKeyMatcher, EachValueMatcher, GenericMatcher, - Matcher, ) from pact.types import UNSET, Matchable, Unset @@ -82,7 +82,7 @@ # # __all__ = [ - "Matcher", + "AbstractMatcher", "array_containing", "bool", "boolean", @@ -149,7 +149,7 @@ def int( *, min: builtins.int | None = None, max: builtins.int | None = None, -) -> Matcher[builtins.int]: +) -> AbstractMatcher[builtins.int]: """ Match an integer value. @@ -183,7 +183,7 @@ def integer( *, min: builtins.int | None = None, max: builtins.int | None = None, -) -> Matcher[builtins.int]: +) -> AbstractMatcher[builtins.int]: """ Alias for [`match.int`][pact.match.int]. """ @@ -198,7 +198,7 @@ def float( /, *, precision: builtins.int | None = None, -) -> Matcher[_NumberT]: +) -> AbstractMatcher[_NumberT]: """ Match a floating point number. @@ -228,7 +228,7 @@ def decimal( /, *, precision: builtins.int | None = None, -) -> Matcher[_NumberT]: +) -> AbstractMatcher[_NumberT]: """ Alias for [`match.float`][pact.match.float]. """ @@ -242,26 +242,26 @@ def number( *, min: builtins.int | None = None, max: builtins.int | None = None, -) -> Matcher[builtins.int]: ... +) -> AbstractMatcher[builtins.int]: ... @overload def number( value: builtins.float, /, *, precision: builtins.int | None = None, -) -> Matcher[builtins.float]: ... +) -> AbstractMatcher[builtins.float]: ... @overload def number( value: Decimal, /, *, precision: builtins.int | None = None, -) -> Matcher[Decimal]: ... +) -> AbstractMatcher[Decimal]: ... @overload def number( value: Unset = UNSET, /, -) -> Matcher[builtins.float]: ... +) -> AbstractMatcher[builtins.float]: ... def number( value: builtins.int | builtins.float | Decimal | Unset = UNSET, # noqa: PYI041 /, @@ -269,7 +269,11 @@ def number( min: builtins.int | None = None, max: builtins.int | None = None, precision: builtins.int | None = None, -) -> Matcher[builtins.int] | Matcher[builtins.float] | Matcher[Decimal]: +) -> ( + AbstractMatcher[builtins.int] + | AbstractMatcher[builtins.float] + | AbstractMatcher[Decimal] +): """ Match a general number. @@ -349,7 +353,7 @@ def str( *, size: builtins.int | None = None, generator: Generator | None = None, -) -> Matcher[builtins.str]: +) -> AbstractMatcher[builtins.str]: """ Match a string value. @@ -399,7 +403,7 @@ def string( *, size: builtins.int | None = None, generator: Generator | None = None, -) -> Matcher[builtins.str]: +) -> AbstractMatcher[builtins.str]: """ Alias for [`match.str`][pact.match.str]. """ @@ -411,7 +415,7 @@ def regex( /, *, regex: builtins.str | None = None, -) -> Matcher[builtins.str]: +) -> AbstractMatcher[builtins.str]: """ Match a string against a regular expression. @@ -456,7 +460,7 @@ def uuid( /, *, format: _UUID_FORMAT_NAMES | None = None, -) -> Matcher[builtins.str]: +) -> AbstractMatcher[builtins.str]: """ Match a UUID value. @@ -505,7 +509,7 @@ def uuid( ) -def bool(value: builtins.bool | Unset = UNSET, /) -> Matcher[builtins.bool]: +def bool(value: builtins.bool | Unset = UNSET, /) -> AbstractMatcher[builtins.bool]: """ Match a boolean value. @@ -521,7 +525,7 @@ def bool(value: builtins.bool | Unset = UNSET, /) -> Matcher[builtins.bool]: return GenericMatcher("boolean", value) -def boolean(value: builtins.bool | Unset = UNSET, /) -> Matcher[builtins.bool]: +def boolean(value: builtins.bool | Unset = UNSET, /) -> AbstractMatcher[builtins.bool]: """ Alias for [`match.bool`][pact.match.bool]. """ @@ -534,7 +538,7 @@ def date( format: builtins.str | None = None, *, disable_conversion: builtins.bool = False, -) -> Matcher[builtins.str]: +) -> AbstractMatcher[builtins.str]: """ Match a date value. @@ -609,7 +613,7 @@ def time( format: builtins.str | None = None, *, disable_conversion: builtins.bool = False, -) -> Matcher[builtins.str]: +) -> AbstractMatcher[builtins.str]: """ Match a time value. @@ -681,7 +685,7 @@ def datetime( format: builtins.str | None = None, *, disable_conversion: builtins.bool = False, -) -> Matcher[builtins.str]: +) -> AbstractMatcher[builtins.str]: """ Match a datetime value. @@ -755,21 +759,21 @@ def timestamp( format: builtins.str | None = None, *, disable_conversion: builtins.bool = False, -) -> Matcher[builtins.str]: +) -> AbstractMatcher[builtins.str]: """ Alias for [`match.datetime`][pact.match.datetime]. """ return datetime(value, format, disable_conversion=disable_conversion) -def none() -> Matcher[None]: +def none() -> AbstractMatcher[None]: """ Match a null value. """ return GenericMatcher("null") -def null() -> Matcher[None]: +def null() -> AbstractMatcher[None]: """ Alias for [`match.none`][pact.match.none]. """ @@ -783,7 +787,7 @@ def type( min: builtins.int | None = None, max: builtins.int | None = None, generator: Generator | None = None, -) -> Matcher[_T]: +) -> AbstractMatcher[_T]: """ Match a value by type. @@ -819,7 +823,7 @@ def like( min: builtins.int | None = None, max: builtins.int | None = None, generator: Generator | None = None, -) -> Matcher[_T]: +) -> AbstractMatcher[_T]: """ Alias for [`match.type`][pact.match.type]. """ @@ -832,7 +836,7 @@ def each_like( *, min: builtins.int | None = None, max: builtins.int | None = None, -) -> Matcher[Sequence[_T]]: # type: ignore[type-var] +) -> AbstractMatcher[Sequence[_T]]: # type: ignore[type-var] """ Match each item in an array against a given value. @@ -865,7 +869,7 @@ def includes( /, *, generator: Generator | None = None, -) -> Matcher[builtins.str]: +) -> AbstractMatcher[builtins.str]: """ Match a string that includes a given value. @@ -886,7 +890,9 @@ def includes( ) -def array_containing(variants: Sequence[_T | Matcher[_T]], /) -> Matcher[Sequence[_T]]: +def array_containing( + variants: Sequence[_T | AbstractMatcher[_T]], / +) -> AbstractMatcher[Sequence[_T]]: """ Match an array that contains the given variants. @@ -907,8 +913,8 @@ def each_key_matches( value: Mapping[_T, Any], /, *, - rules: Matcher[_T] | list[Matcher[_T]], -) -> Matcher[Mapping[_T, Matchable]]: + rules: AbstractMatcher[_T] | list[AbstractMatcher[_T]], +) -> AbstractMatcher[Mapping[_T, Matchable]]: """ Match each key in a dictionary against a set of rules. @@ -922,7 +928,7 @@ def each_key_matches( Returns: Matcher for dictionaries where each key matches the given rules. """ - if isinstance(rules, Matcher): + if isinstance(rules, AbstractMatcher): rules = [rules] return EachKeyMatcher(value=value, rules=rules) @@ -931,8 +937,8 @@ def each_value_matches( value: Mapping[Any, _T], /, *, - rules: Matcher[_T] | list[Matcher[_T]], -) -> Matcher[Mapping[Matchable, _T]]: + rules: AbstractMatcher[_T] | list[AbstractMatcher[_T]], +) -> AbstractMatcher[Mapping[Matchable, _T]]: """ Returns a matcher that matches each value in a dictionary against a set of rules. @@ -946,6 +952,6 @@ def each_value_matches( Returns: Matcher for dictionaries where each value matches the given rules. """ - if isinstance(rules, Matcher): + if isinstance(rules, AbstractMatcher): rules = [rules] return EachValueMatcher(value=value, rules=rules) diff --git a/src/pact/match/matcher.py b/src/pact/match/matcher.py index 95fd672f4..c60103849 100644 --- a/src/pact/match/matcher.py +++ b/src/pact/match/matcher.py @@ -22,7 +22,7 @@ _T = TypeVar("_T") -class Matcher(ABC, Generic[_T_co]): +class AbstractMatcher(ABC, Generic[_T_co]): """ Abstract matcher. @@ -76,7 +76,7 @@ def to_matching_rule(self) -> dict[str, Any]: """ -class GenericMatcher(Matcher[_T_co]): +class GenericMatcher(AbstractMatcher[_T_co]): """ Generic matcher. @@ -197,14 +197,14 @@ def to_matching_rule(self) -> dict[str, Any]: } -class ArrayContainsMatcher(Matcher[Sequence[_T_co]]): +class ArrayContainsMatcher(AbstractMatcher[Sequence[_T_co]]): """ Array contains matcher. A matcher that checks if an array contains a value. """ - def __init__(self, variants: Sequence[_T_co | Matcher[_T_co]]) -> None: + def __init__(self, variants: Sequence[_T_co | AbstractMatcher[_T_co]]) -> None: """ Initialize the matcher. @@ -212,7 +212,7 @@ def __init__(self, variants: Sequence[_T_co | Matcher[_T_co]]) -> None: variants: List of possible values to match against. """ - self._matcher: Matcher[Sequence[_T_co]] = GenericMatcher( + self._matcher: AbstractMatcher[Sequence[_T_co]] = GenericMatcher( "arrayContains", extra_fields={"variants": variants}, ) @@ -224,7 +224,7 @@ def to_matching_rule(self) -> dict[str, Any]: # noqa: D102 return self._matcher.to_matching_rule() -class EachKeyMatcher(Matcher[Mapping[_T, Matchable]]): +class EachKeyMatcher(AbstractMatcher[Mapping[_T, Matchable]]): """ Each key matcher. @@ -234,7 +234,7 @@ class EachKeyMatcher(Matcher[Mapping[_T, Matchable]]): def __init__( self, value: Mapping[_T, Matchable], - rules: list[Matcher[_T]] | None = None, + rules: list[AbstractMatcher[_T]] | None = None, ) -> None: """ Initialize the matcher. @@ -246,7 +246,7 @@ def __init__( rules: List of matchers to apply to each key in the mapping. """ - self._matcher: Matcher[Mapping[_T, Matchable]] = GenericMatcher( + self._matcher: AbstractMatcher[Mapping[_T, Matchable]] = GenericMatcher( "eachKey", value=value, extra_fields={"rules": rules}, @@ -259,7 +259,7 @@ def to_matching_rule(self) -> dict[str, Any]: # noqa: D102 return self._matcher.to_matching_rule() -class EachValueMatcher(Matcher[Mapping[Matchable, _T_co]]): +class EachValueMatcher(AbstractMatcher[Mapping[Matchable, _T_co]]): """ Each value matcher. @@ -269,7 +269,7 @@ class EachValueMatcher(Matcher[Mapping[Matchable, _T_co]]): def __init__( self, value: Mapping[Matchable, _T_co], - rules: list[Matcher[_T_co]] | None = None, + rules: list[AbstractMatcher[_T_co]] | None = None, ) -> None: """ Initialize the matcher. @@ -281,7 +281,7 @@ def __init__( rules: List of matchers to apply to each value in the mapping. """ - self._matcher: Matcher[Mapping[Matchable, _T_co]] = GenericMatcher( + self._matcher: AbstractMatcher[Mapping[Matchable, _T_co]] = GenericMatcher( "eachValue", value=value, extra_fields={"rules": rules}, @@ -312,7 +312,7 @@ def default(self, o: Any) -> Any: # noqa: ANN401 Returns: The encoded object. """ - if isinstance(o, Matcher): + if isinstance(o, AbstractMatcher): return o.to_matching_rule() return super().default(o) @@ -335,7 +335,7 @@ def default(self, o: Any) -> Any: # noqa: ANN401 Returns: The encoded object. """ - if isinstance(o, Matcher): + if isinstance(o, AbstractMatcher): return o.to_integration_json() if isinstance(o, Generator): return o.to_integration_json() From ee2bd556044936fe134d5ca246968ad4344f5339 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 30 Sep 2025 09:01:29 +1000 Subject: [PATCH 2/8] docs: update matcher docs --- src/pact/match/matcher.py | 98 ++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 38 deletions(-) diff --git a/src/pact/match/matcher.py b/src/pact/match/matcher.py index c60103849..b0fad9963 100644 --- a/src/pact/match/matcher.py +++ b/src/pact/match/matcher.py @@ -47,9 +47,8 @@ def to_integration_json(self) -> dict[str, Any]: This method is used internally to convert the matcher to a JSON object which can be embedded directly in a number of places in the Pact FFI. - For more information about this format, see the docs: - - > https://docs.pact.io/implementation_guides/rust/pact_ffi/integrationjson + For more information about this format, see the [integration JSON + docs](https://docs.pact.io/implementation_guides/rust/pact_ffi/integrationjson). Returns: The matcher as an integration JSON object. @@ -63,13 +62,10 @@ def to_matching_rule(self) -> dict[str, Any]: This method is used internally to convert the matcher to a matching rule which can be embedded directly in a Pact file. - For more information about this format, see the docs: - - > https://github.com/pact-foundation/pact-specification/tree/version-4 - - and - - > https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers + For more information about this format, refer to the [Pact + specification](https://github.com/pact-foundation/pact-specification/tree/version-4) + and the [matchers + section](https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers) Returns: The matcher as a matching rule. @@ -151,15 +147,9 @@ def to_integration_json(self) -> dict[str, Any]: """ Convert the matcher to an integration JSON object. - This method is used internally to convert the matcher to a JSON object - which can be embedded directly in a number of places in the Pact FFI. - - For more information about this format, see the docs: - - > https://docs.pact.io/implementation_guides/rust/pact_ffi/integrationjson - - Returns: - The matcher as an integration JSON object. + See + [`AbstractMatcher.to_integration_json`][pact.match.matcher.AbstractMatcher.to_integration_json] + for more information. """ return { "pact:matcher:type": self.type, @@ -176,19 +166,9 @@ def to_matching_rule(self) -> dict[str, Any]: """ Convert the matcher to a matching rule. - This method is used internally to convert the matcher to a matching rule - which can be embedded directly in a Pact file. - - For more information about this format, see the docs: - - > https://github.com/pact-foundation/pact-specification/tree/version-4 - - and - - > https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers - - Returns: - The matcher as a matching rule. + See + [`AbstractMatcher.to_matching_rule`][pact.match.matcher.AbstractMatcher.to_matching_rule] + for more information. """ return { "match": self.type, @@ -217,10 +197,24 @@ def __init__(self, variants: Sequence[_T_co | AbstractMatcher[_T_co]]) -> None: extra_fields={"variants": variants}, ) - def to_integration_json(self) -> dict[str, Any]: # noqa: D102 + def to_integration_json(self) -> dict[str, Any]: + """ + Convert the matcher to an integration JSON object. + + See + [`AbstractMatcher.to_integration_json`][pact.match.matcher.AbstractMatcher.to_integration_json] + for more information. + """ return self._matcher.to_integration_json() - def to_matching_rule(self) -> dict[str, Any]: # noqa: D102 + def to_matching_rule(self) -> dict[str, Any]: + """ + Convert the matcher to a matching rule. + + See + [`AbstractMatcher.to_matching_rule`][pact.match.matcher.AbstractMatcher.to_matching_rule] + for more information. + """ return self._matcher.to_matching_rule() @@ -252,10 +246,24 @@ def __init__( extra_fields={"rules": rules}, ) - def to_integration_json(self) -> dict[str, Any]: # noqa: D102 + def to_integration_json(self) -> dict[str, Any]: + """ + Convert the matcher to an integration JSON object. + + See + [`AbstractMatcher.to_integration_json`][pact.match.matcher.AbstractMatcher.to_integration_json] + for more information. + """ return self._matcher.to_integration_json() - def to_matching_rule(self) -> dict[str, Any]: # noqa: D102 + def to_matching_rule(self) -> dict[str, Any]: + """ + Convert the matcher to a matching rule. + + See + [`AbstractMatcher.to_matching_rule`][pact.match.matcher.AbstractMatcher.to_matching_rule] + for more information. + """ return self._matcher.to_matching_rule() @@ -287,10 +295,24 @@ def __init__( extra_fields={"rules": rules}, ) - def to_integration_json(self) -> dict[str, Any]: # noqa: D102 + def to_integration_json(self) -> dict[str, Any]: + """ + Convert the matcher to an integration JSON object. + + See + [`AbstractMatcher.to_integration_json`][pact.match.matcher.AbstractMatcher.to_integration_json] + for more information. + """ return self._matcher.to_integration_json() - def to_matching_rule(self) -> dict[str, Any]: # noqa: D102 + def to_matching_rule(self) -> dict[str, Any]: + """ + Convert the matcher to a matching rule. + + See + [`AbstractMatcher.to_matching_rule`][pact.match.matcher.AbstractMatcher.to_matching_rule] + for more information. + """ return self._matcher.to_matching_rule() From b8a2fa0204c19ec6f62632452572693bc1be9400 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 30 Sep 2025 09:09:02 +1000 Subject: [PATCH 3/8] docs: improve matchers Signed-off-by: JP-Ellis --- src/pact/match/__init__.py | 455 ++++++++++++++++++++++++------------- 1 file changed, 301 insertions(+), 154 deletions(-) diff --git a/src/pact/match/__init__.py b/src/pact/match/__init__.py index 43a6050a6..f80b067d0 100644 --- a/src/pact/match/__init__.py +++ b/src/pact/match/__init__.py @@ -1,45 +1,241 @@ -""" +r""" Matching functionality. -This module provides the functionality to define matching rules to be used -within a Pact contract. These rules define the expected content of the data -being exchanged in a way that is more flexible than a simple equality check. +This module defines flexible matching rules for use in Pact contracts. These +rules specify the expected content of exchanged data, allowing for more robust +contract testing than simple equality checks. -As an example, a contract may define how a new record is to be created through a -POST request. The consumer would define the new information to be sent, and the -expected response. The response may contain additional data added by the -provider, such as an ID and a creation timestamp. The contract would define that -the ID is of a specific format (e.g., an integer or a UUID), and that the -creation timestamp is ISO 8601 formatted. +For example, a contract may specify how a new record is created via a POST +request. The consumer defines the data to send and the expected response. The +response may include additional fields from the provider, such as an ID or +creation timestamp. The contract can require the ID to match a specific format +(e.g., integer or UUID) and the timestamp to be ISO 8601. !!! warning Do not import functions directly from this module. Instead, import the - `match` module and use the functions from there: + `match` module and use its functions: ```python - # Good + # Recommended from pact import match match.int(...) - # Bad + # Not recommended from pact.match import int int(...) ``` -A number of functions in this module are named after the types they match (e.g., -`int`, `str`, `bool`). These functions will have aliases as well for better -interoperability with the rest of the Pact ecosystem. It is important to note -that these functions will shadow the built-in types if imported directly from -this module. This is why we recommend importing the `match` module and using the -functions from there. - -Matching rules are frequently combined with generators which allow for Pact to -generate values on the fly during contract testing. As a general rule for the -functions below, if a `value` is _not_ provided, a generator will be used; and -conversely, if a `value` is provided, a generator will _not_ be used. +Many functions in this module are named after the types they match (e.g., `int`, +`str`, `bool`). Importing directly from this module may shadow Python built-in +types, so always use the `match` module. + +Matching rules are often combined with generators, which allow Pact to produce +values dynamically during contract tests. If a `value` is not provided, a +generator is used; if a `value` is provided, a generator is not used. This is +_not_ advised, as leads to non-deterministic tests. + +!!! note + + You do not need to specify everything that will be returned from the + provider in a JSON response. Any extra data that is received will be + ignored and the tests will still pass, as long as the expected fields + match the defined patterns. + +For more information about the Pact matching specification, see +[Matching](https://docs.pact.io/getting_started/matching). + +## Type Matching + +The most common matchers validate that values are of a specific type. These +matchers can optionally accept example values: + +```python +from pact import match + +response = { + "id": match.int(123), # Any integer (example: 123) + "name": match.str("Alice"), # Any string (example: "Alice") + "score": match.float(98.5), # Any float (example: 98.5) + "active": match.bool(True), # Any boolean (example: True) + "tags": match.each_like("admin"), # Array of strings (example: ["admin"]) +} +``` + +When no example value is provided, Pact will generate appropriate values +automatically, but this is _not_ advised, as it leads to non-deterministic +tests. + +## Regular Expression Matching + +For values that must match a specific pattern, use `match.regex()` with a +regular expression: + +```python +response = { + "reference": match.regex("X1234-456def", regex=r"[A-Z]\d{3,6}-[0-9a-f]{6}"), + "phone": match.regex("+1-555-123-4567", regex=r"\+1-\d{3}-\d{3}-\d{4}"), +} +``` + +Note that the regular expression should be provided as a raw string (using the +`r"..."` syntax) to avoid issues with escape sequences. Advanced regex features +like lookaheads and lookbehinds should be avoided, as they may not be supported +by all Pact implementations. + +## Complex Objects + +For complex nested objects, matchers can be combined to create sophisticated +matching rules: + +```python +from pact import match + +user_response = { + "id": match.int(123), + "name": match.str("Alice"), + "email": match.regex( + "alice@example.com", + regex=r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", + ), + "confirmed": match.bool(True), + "address": { + "street": match.str("123 Main St"), + "city": match.str("Anytown"), + "postal_code": match.regex("12345", regex=r"\d{5}"), + }, + "roles": match.each_like(match.str("admin")), # Array of strings +} +``` + +The `match.type()` (or its alias `match.like()`) function provides generic type +matching for any value: + +```python +# These are equivalent to the specific type matchers +response = { + "id": match.type(123), # Same as match.int(123) + "name": match.like("Alice"), # Same as match.str("Alice") +} +``` + +## Array Matching + +For arrays where each element should match a specific pattern, use +`match.each_like()`: + +```python +from pact import match + +# Simple arrays +response = { + "tags": match.each_like(match.str("admin")), # Array of strings + "scores": match.each_like(match.int(95)), # Array of integers + "active": match.each_like(match.bool(True)), # Array of booleans +} + +# Complex nested objects in arrays +users_response = { + "users": match.each_like({ + "id": match.int(123), + "username": match.regex("alice123", regex=r"[a-zA-Z]+\d*"), + "roles": match.each_like(match.str("user")), # Nested array + }) +} +``` + +You can also control the minimum and maximum number of array elements: + +```python +response = {"items": match.each_like(match.str("item"), min=1, max=10)} +``` + +For arrays that must contain specific elements regardless of order, use +`match.array_containing()`. For example, to ensure an array includes certain +permissions: + +```python +response = { + "permissions": match.array_containing([ + match.str("read"), + match.str("write"), + match.regex("admin-edit", regex=r"admin-\w+"), + ]) +} +``` + +Note that additional elements may be present in the array; the matcher only +ensures the specified elements are included. + +## Date and Time Matching + +The `match` module provides specialized matchers for date and time values: + +```python +from pact import match +from datetime import date, time, datetime + +response = { + # Date matching (YYYY-MM-DD format by default) + "birth_date": match.date("2024-07-20"), + "birth_date_obj": match.date(date(2024, 7, 20)), + # Time matching (HH:MM:SS format by default) + "start_time": match.time("14:30:00"), + "start_time_obj": match.time(time(14, 30, 0)), + # DateTime matching (ISO 8601 format by default) + "created_at": match.datetime("2024-07-20T14:30:00+00:00"), + "updated_at": match.datetime(datetime(2024, 7, 20, 14, 30, 0)), + # Custom formats using Python strftime patterns + "custom_date": match.date("07/20/2024", format="%m/%d/%Y"), + "custom_time": match.time("2:30 PM", format="%I:%M %p"), +} +``` + +## Specialized Matchers + +Other commonly used matchers include: + +```python +from pact import match + +response = { + # UUID matching with different formats + "id": match.uuid("550e8400-e29b-41d4-a716-446655440000"), + "simple_id": match.uuid(format="simple"), # No hyphens + "uppercase_id": match.uuid(format="uppercase"), # Uppercase letters + # Number matching with constraints + "age": match.int(25, min=18, max=99), + "price": match.float(19.99, precision=2), + "count": match.number(42), # Generic number matcher + # String matching with constraints + "username": match.str("alice123", size=8), + "description": match.str(), # Any string + # Null values + "optional_field": match.none(), # or match.null() + # String inclusion matching + "message": match.includes("success"), # Must contain "success" +} +``` + +## Advanced Dictionary Matching + +For dynamic dictionary structures, you can match keys and values separately: + +```python +# Match each key against a pattern +user_permissions = match.each_key_matches( + {"admin-read": True, "admin-write": False}, + rules=match.regex("admin-read", regex=r"admin-\w+"), +) + +# Match each value against a pattern +user_scores = match.each_value_matches( + {"math": 95, "science": 87}, rules=match.int(85, min=0, max=100) +) +``` + """ from __future__ import annotations @@ -126,8 +322,8 @@ def __import__( # noqa: N807 """ Override to warn when importing functions directly from this module. - This function is used to override the built-in `__import__` function to warn - users when they import functions directly from this module. This is done to + This function overrides the built-in `__import__` to warn + users when importing functions directly from this module, helping to avoid shadowing built-in types and functions. """ __tracebackhide__ = True @@ -155,13 +351,13 @@ def int( Args: value: - Default value to use when generating a consumer test. + Example value for consumer test generation. min: - If provided, the minimum value of the integer to generate. + Minimum value to generate, if set. max: - If provided, the maximum value of the integer to generate. + Maximum value to generate, if set. Returns: Matcher for integer values. @@ -200,17 +396,17 @@ def float( precision: builtins.int | None = None, ) -> AbstractMatcher[_NumberT]: """ - Match a floating point number. + Match a floating-point number. Args: value: - Default value to use when generating a consumer test. + Example value for consumer test generation. precision: - The number of decimal precision to generate. + Number of decimal places to generate. Returns: - Matcher for floating point numbers. + Matcher for floating-point numbers. """ if value is UNSET: return GenericMatcher( @@ -275,27 +471,20 @@ def number( | AbstractMatcher[Decimal] ): """ - Match a general number. - - This matcher is a generalization of the [`integer`][pact.match.integer] - and [`decimal`][pact.match.decimal] matchers. It can be used to match any - number, whether it is an integer or a float. + Match any number (integer, float, or Decimal). Args: value: - Default value to use when generating a consumer test. + Example value for consumer test generation. min: - The minimum value of the number to generate. Only used when value is - an integer. + Minimum value to generate (for integers). max: - The maximum value of the number to generate. Only used when value is - an integer. + Maximum value to generate (for integers). precision: - The number of decimal digits to generate. Only used when value is a - float. + Number of decimal digits to generate (for floats). Returns: Matcher for numbers (integer, float, or Decimal). @@ -355,21 +544,17 @@ def str( generator: Generator | None = None, ) -> AbstractMatcher[builtins.str]: """ - Match a string value. - - This function can be used to match a string value, merely verifying that the - value is a string, possibly with a specific length. + Match a string value, optionally with a specific length. Args: value: - Default value to use when generating a consumer test. + Example value for consumer test generation. size: - The size of the string to generate during a consumer test. + Length of string to generate for consumer test. generator: - Alternative generator to use when generating a consumer test. - If set, the `size` argument is ignored. + Alternative generator for consumer test. If set, ignores `size`. Returns: Matcher for string values. @@ -421,10 +606,10 @@ def regex( Args: value: - Default value to use when generating a consumer test. + Example value for consumer test generation. regex: - The regular expression to match against. + Regular expression pattern to match. Returns: Matcher for strings matching the given regular expression. @@ -464,29 +649,24 @@ def uuid( """ Match a UUID value. - This matcher internally combines the [`regex`][pact.match.regex] matcher - with a UUID regex pattern. See [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122) - for details about the UUID format. - - While RFC 4122 requires UUIDs to be output as lowercase, UUIDs are case - insensitive on input. Some common alternative formats can be enforced using - the `format` parameter. + See [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122) for details + about the UUID format. Some common, albeit non-compliant, alternative + formats are also supported. Args: value: - Default value to use when generating a consumer test. + Example value for consumer test generation. format: - Enforce a specific UUID format. The following formats are supported: + Specify UUID format: - - `simple`: 32 hexadecimal digits with no hyphens. This is _not_ a - valid UUID format, but is provided for convenience. + - `simple`: 32 hexadecimal digits, no hyphens (not standard, for + convenience). - `lowercase`: Lowercase hexadecimal digits with hyphens. - `uppercase`: Uppercase hexadecimal digits with hyphens. - - `urn`: Lowercase hexadecimal digits with hyphens and a - `urn:uuid:` + - `urn`: Lowercase hexadecimal digits with hyphens and `urn:uuid:` prefix. - If not provided, the matcher will accept any lowercase or uppercase. + If not set, matches any case. Returns: Matcher for UUID strings. @@ -515,7 +695,7 @@ def bool(value: builtins.bool | Unset = UNSET, /) -> AbstractMatcher[builtins.bo Args: value: - Default value to use when generating a consumer test. + Example value for consumer test generation. Returns: Matcher for boolean values. @@ -540,28 +720,20 @@ def date( disable_conversion: builtins.bool = False, ) -> AbstractMatcher[builtins.str]: """ - Match a date value. - - A date value is a string that represents a date in a specific format. It - does _not_ have any time information. + Match a date value (string, no time component). - !!! info - - Pact internally uses the Java's - [`SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html). - To ensure compatibility with the rest of the Python ecosystem, this - function accepts Python's - [`strftime`](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) - format and performs the conversion to Java's format internally. + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for Pact compatibility. Args: value: - Default value to use when generating a consumer test. + Example value for consumer test generation. format: - Expected format of the date. - - If not provided, an ISO 8601 date format will be used: `%Y-%m-%d`. + Date format string. Defaults to ISO 8601 (`%Y-%m-%d`). disable_conversion: If True, the conversion from Python's `strftime` format to Java's @@ -615,33 +787,24 @@ def time( disable_conversion: builtins.bool = False, ) -> AbstractMatcher[builtins.str]: """ - Match a time value. - - A time value is a string that represents a time in a specific format. It - does _not_ have any date information. - - !!! info + Match a time value (string, no date component). - Pact internally uses the Java's - [`SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html). - To ensure compatibility with the rest of the Python ecosystem, this - function accepts Python's [`strftime`](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) - format and performs the conversion to Java's format internally. + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for Pact compatibility. Args: value: - Default value to use when generating a consumer test. + Example value for consumer test generation. format: - Expected format of the time. - - If not provided, an ISO 8601 time format will be used: `%H:%M:%S`. + Time format string. Defaults to ISO 8601 (`%H:%M:%S`). disable_conversion: - If True, the conversion from Python's `strftime` format to Java's - `SimpleDateFormat` format will be disabled, and the format must be - in Java's `SimpleDateFormat` format. As a result, the value must - be a string as Python cannot format the time in the target format. + If True, disables conversion and expects Java format. Value must be + a string. Returns: Matcher for time strings. @@ -687,35 +850,24 @@ def datetime( disable_conversion: builtins.bool = False, ) -> AbstractMatcher[builtins.str]: """ - Match a datetime value. - - A timestamp value is a string that represents a date and time in a specific - format. + Match a datetime value (string, date and time). - !!! info - - Pact internally uses the Java's - [`SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html). - To ensure compatibility with the rest of the Python ecosystem, this - function accepts Python's - [`strftime`](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) - format and performs the conversion to Java's format internally. + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for Pact compatibility. Args: value: - Default value to use when generating a consumer test. + Example value for consumer test generation. format: - Expected format of the timestamp. - - If not provided, an ISO 8601 timestamp format will be used: - `%Y-%m-%dT%H:%M:%S%z`. + Datetime format string. Defaults to ISO 8601 (`%Y-%m-%dT%H:%M:%S%z`). disable_conversion: - If True, the conversion from Python's `strftime` format to Java's - `SimpleDateFormat` format will be disabled, and the format must be - in Java's `SimpleDateFormat` format. As a result, the value must be - a string as Python cannot format the timestamp in the target format. + If True, disables conversion and expects Java format. Value must be + a string. Returns: Matcher for datetime strings. @@ -789,21 +941,20 @@ def type( generator: Generator | None = None, ) -> AbstractMatcher[_T]: """ - Match a value by type. + Match a value by type (primitive or complex). Args: value: - A value to match against. This can be a primitive value, or a more - complex object or array. + Value to match (primitive or complex). min: - The minimum number of items that must match the value. + Minimum number of items to match. max: - The maximum number of items that must match the value. + Maximum number of items to match. generator: - The generator to use when generating the value. + Generator to use for value generation. Returns: Matcher for the given value type. @@ -838,23 +989,20 @@ def each_like( max: builtins.int | None = None, ) -> AbstractMatcher[Sequence[_T]]: # type: ignore[type-var] """ - Match each item in an array against a given value. - - The value itself is arbitrary, and can include other matchers. + Match each item in an array against a value (can be a matcher). Args: value: - The value to match against. + Value to match against (can be a matcher). min: - The minimum number of items that must match the value. The minimum - value is always 1, even if min is set to 0. + Minimum number of items to match (minimum is always 1). max: - The maximum number of items that must match the value. + Maximum number of items to match. Returns: - Matcher for arrays where each item matches the given value. + Matcher for arrays where each item matches the value. """ if min is not None and min < 1: warnings.warn( @@ -875,10 +1023,10 @@ def includes( Args: value: - The value to match against. + Value to match against. generator: - The generator to use when generating the value. + Generator to use for value generation. Returns: Matcher for strings that include the given value. @@ -894,14 +1042,13 @@ def array_containing( variants: Sequence[_T | AbstractMatcher[_T]], / ) -> AbstractMatcher[Sequence[_T]]: """ - Match an array that contains the given variants. + Match an array containing the given variants. - Matching is successful if each variant occurs once in the array. Variants - may be objects containing matching rules. + Each variant must occur at least once. Variants may be matchers or objects. Args: variants: - A list of variants to match against. + List of variants to match against. Returns: Matcher for arrays containing the given variants. @@ -916,17 +1063,17 @@ def each_key_matches( rules: AbstractMatcher[_T] | list[AbstractMatcher[_T]], ) -> AbstractMatcher[Mapping[_T, Matchable]]: """ - Match each key in a dictionary against a set of rules. + Match each key in a dictionary against rules. Args: value: - The value to match against. + Dictionary to match against. rules: - The matching rules to match against each key. + Matching rules for each key. Returns: - Matcher for dictionaries where each key matches the given rules. + Matcher for dictionaries where each key matches the rules. """ if isinstance(rules, AbstractMatcher): rules = [rules] @@ -940,17 +1087,17 @@ def each_value_matches( rules: AbstractMatcher[_T] | list[AbstractMatcher[_T]], ) -> AbstractMatcher[Mapping[Matchable, _T]]: """ - Returns a matcher that matches each value in a dictionary against a set of rules. + Match each value in a dictionary against rules. Args: value: - The value to match against. + Dictionary to match against. rules: - The matching rules to match against each value. + Matching rules for each value. Returns: - Matcher for dictionaries where each value matches the given rules. + Matcher for dictionaries where each value matches the rules. """ if isinstance(rules, AbstractMatcher): rules = [rules] From 02976cbb20afc2bc6e4590d3ef32b02a8645547f Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 30 Sep 2025 09:36:14 +1000 Subject: [PATCH 4/8] docs: improve generators Signed-off-by: JP-Ellis --- src/pact/generate/__init__.py | 256 ++++++++++++++++++++-------------- 1 file changed, 155 insertions(+), 101 deletions(-) diff --git a/src/pact/generate/__init__.py b/src/pact/generate/__init__.py index c436a1290..ca530b3c0 100644 --- a/src/pact/generate/__init__.py +++ b/src/pact/generate/__init__.py @@ -1,5 +1,88 @@ -""" -Generator module. +r""" +Generator functionality. + +This module provides flexible value generators for use in Pact contracts. +Generators allow you to specify dynamic values for contract testing, ensuring +that your tests remain robust and non-deterministic where appropriate. These +generators are typically used in conjunction with matchers to produce example +data for consumer-driven contract tests. + +Generators are essential for producing dynamic values in contract tests, such as +random integers, dates, UUIDs, and more. This helps ensure that your contracts +are resilient to changes and do not rely on hardcoded values, which can lead to +brittle tests. + +!!! warning + + Do not import functions directly from `pact.generate` to avoid shadowing + Python built-in types. Instead, import the `generate` module and use its + functions as `generate.int`, `generate.str`, etc. + + ```python + # Recommended + from pact import generate + + generate.int(...) + + # Not recommended + from pact.generate import int + + int(...) + ``` + +Many functions in this module are named after the type they generate (e.g., +`int`, `str`, `bool`). Importing directly from this module may shadow Python +built-in types, so always use the `generate` module. + +Generators are typically used in conjunction with matchers, which allow Pact to +validate values during contract tests. If a `value` is not provided within a +matcher, a generator will produce a random value that conforms to the specified +constraints. + +## Basic Types + +Generate random values for basic types: + +```python +from pact import generate + +random_bool = generate.bool() +random_int = generate.int(min=0, max=100) +random_float = generate.float(precision=2) +random_str = generate.str(size=12) +``` + +## Dates, Times, and UUIDs + +Produce values in specific formats: + +```python +random_date = generate.date(format="%Y-%m-%d") +random_time = generate.time(format="%H:%M:%S") +random_datetime = generate.datetime(format="%Y-%m-%dT%H:%M:%S%z") +random_uuid = generate.uuid(format="lowercase") +``` + +## Regex and Hexadecimal + +Generate values matching a pattern or hexadecimal format: + +```python +random_code = generate.regex(r"[A-Z]{3}-\d{4}") +random_hex = generate.hex(digits=8) +``` + +### Provider State and Mock Server URLs + +For advanced contract scenarios: + +```python +provider_value = generate.provider_state(expression="user_id") +mock_url = generate.mock_server_url(regex=r"http://localhost:\d+") +``` + +For more details and advanced usage, see the documentation for each function +below. """ from __future__ import annotations @@ -91,17 +174,17 @@ def int( max: builtins.int | None = None, ) -> Generator: """ - Create a random integer generator. + Generate a random integer. Args: min: - The minimum value for the integer. + Minimum value for the integer. max: - The maximum value for the integer. + Maximum value for the integer. Returns: - A generator that produces random integers. + Generator producing random integers. """ params: dict[builtins.str, builtins.int] = {} if min is not None: @@ -121,31 +204,30 @@ def integer( Args: min: - The minimum value for the integer. + Minimum value for the integer. max: - The maximum value for the integer. + Maximum value for the integer. Returns: - A generator that produces random integers. + Generator producing random integers. """ return int(min=min, max=max) def float(precision: builtins.int | None = None) -> Generator: """ - Create a random decimal generator. + Generate a random decimal number. - Note that the precision is the number of digits to generate _in total_, not - the number of decimal places. Therefore a precision of `3` will generate - numbers like `0.123` and `12.3`. + Precision refers to the total number of digits (excluding leading zeros), + not decimal places. For example, precision of 3 may yield `0.123` or `12.3`. Args: precision: - The number of digits to generate. + Number of digits to generate. Returns: - A generator that produces random decimal values. + Generator producing random decimal values. """ params: dict[builtins.str, builtins.int] = {} if precision is not None: @@ -159,24 +241,24 @@ def decimal(precision: builtins.int | None = None) -> Generator: Args: precision: - The number of digits to generate. + Number of digits to generate. Returns: - A generator that produces random decimal values. + Generator producing random decimal values. """ return float(precision=precision) def hex(digits: builtins.int | None = None) -> Generator: """ - Create a random hexadecimal generator. + Generate a random hexadecimal value. Args: digits: - The number of digits to generate. + Number of digits to generate. Returns: - A generator that produces random hexadecimal values. + Generator producing random hexadecimal values. """ params: dict[builtins.str, builtins.int] = {} if digits is not None: @@ -190,24 +272,24 @@ def hexadecimal(digits: builtins.int | None = None) -> Generator: Args: digits: - The number of digits to generate. + Number of digits to generate. Returns: - A generator that produces random hexadecimal values. + Generator producing random hexadecimal values. """ return hex(digits=digits) def str(size: builtins.int | None = None) -> Generator: """ - Create a random string generator. + Generate a random string. Args: size: - The size of the string to generate. + Size of the string to generate. Returns: - A generator that produces random strings. + Generator producing random strings. """ params: dict[builtins.str, builtins.int] = {} if size is not None: @@ -221,26 +303,24 @@ def string(size: builtins.int | None = None) -> Generator: Args: size: - The size of the string to generate. + Size of the string to generate. Returns: - A generator that produces random strings. + Generator producing random strings. """ return str(size=size) def regex(regex: builtins.str) -> Generator: """ - Create a regex generator. - - The generator will generate a string that matches the given regex pattern. + Generate a string matching a regex pattern. Args: regex: - The regex pattern to match. + Regex pattern to match. Returns: - A generator that produces strings matching the given regex pattern. + Generator producing strings matching the pattern. """ return GenericGenerator("Regex", {"regex": regex}) @@ -258,15 +338,14 @@ def uuid( format: _UUID_FORMAT_NAMES = "lowercase", ) -> Generator: """ - Create a UUID generator. + Generate a UUID. Args: format: - The format of the UUID to generate. This parameter is only supported - under the V4 specification. + Format of the UUID to generate. Only supported under the V4 specification. Returns: - A generator that produces UUIDs in the specified format. + Generator producing UUIDs in the specified format. """ return GenericGenerator("Uuid", {"format": format}) @@ -277,30 +356,24 @@ def date( disable_conversion: builtins.bool = False, ) -> Generator: """ - Create a date generator. + Generate a date value. - !!! info - - Pact internally uses the Java's - [`SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html). - To ensure compatibility with the rest of the Python ecosystem, this - function accepts Python's [`strftime`](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) - format and performs the conversion to Java's format internally. + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for Pact compatibility. Args: format: - Expected format of the date. - - If not provided, an ISO 8601 date format is used: `%Y-%m-%d`. + Expected format of the date. Defaults to ISO 8601: `%Y-%m-%d`. disable_conversion: - If True, the conversion from Python's `strftime` format to Java's - `SimpleDateFormat` format will be disabled, and the format must - be in Java's `SimpleDateFormat` format. As a result, the value must - be a string as Python cannot format the date in the target format. + If True, disables conversion from Python's format to Java's format. + The value must then be a Java format string. Returns: - A generator that produces dates in the specified format. + Generator producing dates in the specified format. """ if not disable_conversion: format = strftime_to_simple_date_format(format or "%Y-%m-%d") @@ -313,31 +386,23 @@ def time( disable_conversion: builtins.bool = False, ) -> Generator: """ - Create a time generator. + Generate a time value. - !!! info - - Pact internally uses the Java's - [`SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html). - To ensure compatibility with the rest of the Python ecosystem, this - function accepts Python's - [`strftime`](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) - format and performs the conversion to Java's format internally. + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) Args: format: - Expected format of the time. - - If not provided, an ISO 8601 time format will be used: `%H:%M:%S`. + Expected format of the time. Defaults to ISO 8601: `%H:%M:%S`. disable_conversion: - If True, the conversion from Python's `strftime` format to Java's - `SimpleDateFormat` format will be disabled, and the format must be - in Java's `SimpleDateFormat` format. As a result, the value must be - a string as Python cannot format the time in the target format. + If True, disables conversion from Python's format to Java's format. + The value must then be a Java format string. Returns: - A generator that produces times in the specified format. + Generator producing times in the specified format. """ if not disable_conversion: format = strftime_to_simple_date_format(format or "%H:%M:%S") @@ -350,31 +415,25 @@ def datetime( disable_conversion: builtins.bool = False, ) -> Generator: """ - Create a datetime generator. + Generate a datetime value. - !!! info - - Pact internally uses the Java's - [`SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html). - To ensure compatibility with the rest of the Python ecosystem, this - function accepts Python's - [`strftime`](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) - format and performs the conversion to Java's format internally. + Uses Python's + [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) + format, converted to [Java + `SimpleDateFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for Pact compatibility. Args: format: - Expected format of the timestamp. - - If not provided, an ISO 8601 timestamp format will be used: + Expected format of the timestamp. Defaults to ISO 8601: `%Y-%m-%dT%H:%M:%S%z`. disable_conversion: - If True, the conversion from Python's `strftime` format to Java's - `SimpleDateFormat` format will be disabled, and the format must be - in Java's `SimpleDateFormat` format. As a result, the value must be + If True, disables conversion from Python's format to Java's format. + The value must then be a Java format string. Returns: - A generator that produces datetimes in the specified format. + Generator producing datetimes in the specified format. """ if not disable_conversion: format = strftime_to_simple_date_format(format or "%Y-%m-%dT%H:%M:%S%z") @@ -390,17 +449,17 @@ def timestamp( Alias for [`generate.datetime`][pact.generate.datetime]. Returns: - A generator that produces datetimes in the specified format. + Generator producing datetimes in the specified format. """ return datetime(format=format, disable_conversion=disable_conversion) def bool() -> Generator: """ - Create a random boolean generator. + Generate a random boolean value. Returns: - A generator that produces random boolean values. + Generator producing random boolean values. """ return GenericGenerator("RandomBoolean") @@ -410,24 +469,21 @@ def boolean() -> Generator: Alias for [`generate.bool`][pact.generate.bool]. Returns: - A generator that produces random boolean values. + Generator producing random boolean values. """ return bool() def provider_state(expression: builtins.str | None = None) -> Generator: """ - Create a provider state generator. - - Generates a value that is looked up from the provider state context - using the given expression. + Generate a value from provider state context. Args: expression: - The expression to use to look up the provider state. + Expression to look up provider state. Returns: - A generator that produces values from the provider state context. + Generator producing values from provider state context. """ params: dict[builtins.str, builtins.str] = {} if expression is not None: @@ -440,19 +496,17 @@ def mock_server_url( example: builtins.str | None = None, ) -> Generator: """ - Create a mock server URL generator. - - Generates a URL with the mock server as the base URL. + Generate a mock server URL. Args: regex: - The regex pattern to match. + Regex pattern to match. example: - An example URL to use. + Example URL to use. Returns: - A generator that produces mock server URLs. + Generator producing mock server URLs. """ params: dict[builtins.str, builtins.str] = {} if regex is not None: From 9c4d889a6218ec8959cd78b20d1c909e35265e46 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 30 Sep 2025 09:39:03 +1000 Subject: [PATCH 5/8] chore!: rename abstract generator Make clear it is abstract by naming it `AbstractGenerator`. BREAKING CHANGE: The abstract `pact.generate.Generator` class has been renamed to `pact.generate.AbstractGenerator`. Signed-off-by: JP-Ellis --- src/pact/generate/__init__.py | 40 +++++++++++------------ src/pact/generate/generator.py | 59 ++++++++++------------------------ src/pact/match/__init__.py | 12 +++---- src/pact/match/matcher.py | 6 ++-- 4 files changed, 46 insertions(+), 71 deletions(-) diff --git a/src/pact/generate/__init__.py b/src/pact/generate/__init__.py index ca530b3c0..4104de3c5 100644 --- a/src/pact/generate/__init__.py +++ b/src/pact/generate/__init__.py @@ -93,7 +93,7 @@ from pact._util import strftime_to_simple_date_format from pact.generate.generator import ( - Generator, + AbstractGenerator, GenericGenerator, ) @@ -116,7 +116,7 @@ # # __all__ = [ - "Generator", + "AbstractGenerator", "bool", "boolean", "date", @@ -172,7 +172,7 @@ def int( *, min: builtins.int | None = None, max: builtins.int | None = None, -) -> Generator: +) -> AbstractGenerator: """ Generate a random integer. @@ -198,7 +198,7 @@ def integer( *, min: builtins.int | None = None, max: builtins.int | None = None, -) -> Generator: +) -> AbstractGenerator: """ Alias for [`generate.int`][pact.generate.int]. @@ -215,7 +215,7 @@ def integer( return int(min=min, max=max) -def float(precision: builtins.int | None = None) -> Generator: +def float(precision: builtins.int | None = None) -> AbstractGenerator: """ Generate a random decimal number. @@ -235,7 +235,7 @@ def float(precision: builtins.int | None = None) -> Generator: return GenericGenerator("RandomDecimal", extra_fields=params) -def decimal(precision: builtins.int | None = None) -> Generator: +def decimal(precision: builtins.int | None = None) -> AbstractGenerator: """ Alias for [`generate.float`][pact.generate.float]. @@ -249,7 +249,7 @@ def decimal(precision: builtins.int | None = None) -> Generator: return float(precision=precision) -def hex(digits: builtins.int | None = None) -> Generator: +def hex(digits: builtins.int | None = None) -> AbstractGenerator: """ Generate a random hexadecimal value. @@ -266,7 +266,7 @@ def hex(digits: builtins.int | None = None) -> Generator: return GenericGenerator("RandomHexadecimal", extra_fields=params) -def hexadecimal(digits: builtins.int | None = None) -> Generator: +def hexadecimal(digits: builtins.int | None = None) -> AbstractGenerator: """ Alias for [`generate.hex`][pact.generate.hex]. @@ -280,7 +280,7 @@ def hexadecimal(digits: builtins.int | None = None) -> Generator: return hex(digits=digits) -def str(size: builtins.int | None = None) -> Generator: +def str(size: builtins.int | None = None) -> AbstractGenerator: """ Generate a random string. @@ -297,7 +297,7 @@ def str(size: builtins.int | None = None) -> Generator: return GenericGenerator("RandomString", extra_fields=params) -def string(size: builtins.int | None = None) -> Generator: +def string(size: builtins.int | None = None) -> AbstractGenerator: """ Alias for [`generate.str`][pact.generate.str]. @@ -311,7 +311,7 @@ def string(size: builtins.int | None = None) -> Generator: return str(size=size) -def regex(regex: builtins.str) -> Generator: +def regex(regex: builtins.str) -> AbstractGenerator: """ Generate a string matching a regex pattern. @@ -336,7 +336,7 @@ def regex(regex: builtins.str) -> Generator: def uuid( format: _UUID_FORMAT_NAMES = "lowercase", -) -> Generator: +) -> AbstractGenerator: """ Generate a UUID. @@ -354,7 +354,7 @@ def date( format: builtins.str = "%Y-%m-%d", *, disable_conversion: builtins.bool = False, -) -> Generator: +) -> AbstractGenerator: """ Generate a date value. @@ -384,7 +384,7 @@ def time( format: builtins.str = "%H:%M:%S", *, disable_conversion: builtins.bool = False, -) -> Generator: +) -> AbstractGenerator: """ Generate a time value. @@ -413,7 +413,7 @@ def datetime( format: builtins.str, *, disable_conversion: builtins.bool = False, -) -> Generator: +) -> AbstractGenerator: """ Generate a datetime value. @@ -444,7 +444,7 @@ def timestamp( format: builtins.str, *, disable_conversion: builtins.bool = False, -) -> Generator: +) -> AbstractGenerator: """ Alias for [`generate.datetime`][pact.generate.datetime]. @@ -454,7 +454,7 @@ def timestamp( return datetime(format=format, disable_conversion=disable_conversion) -def bool() -> Generator: +def bool() -> AbstractGenerator: """ Generate a random boolean value. @@ -464,7 +464,7 @@ def bool() -> Generator: return GenericGenerator("RandomBoolean") -def boolean() -> Generator: +def boolean() -> AbstractGenerator: """ Alias for [`generate.bool`][pact.generate.bool]. @@ -474,7 +474,7 @@ def boolean() -> Generator: return bool() -def provider_state(expression: builtins.str | None = None) -> Generator: +def provider_state(expression: builtins.str | None = None) -> AbstractGenerator: """ Generate a value from provider state context. @@ -494,7 +494,7 @@ def provider_state(expression: builtins.str | None = None) -> Generator: def mock_server_url( regex: builtins.str | None = None, example: builtins.str | None = None, -) -> Generator: +) -> AbstractGenerator: """ Generate a mock server URL. diff --git a/src/pact/generate/generator.py b/src/pact/generate/generator.py index f6f8e21c0..3df502107 100644 --- a/src/pact/generate/generator.py +++ b/src/pact/generate/generator.py @@ -14,7 +14,7 @@ from pact.types import GeneratorType -class Generator(ABC): +class AbstractGenerator(ABC): """ Abstract generator. @@ -34,17 +34,11 @@ class Generator(ABC): @abstractmethod def to_integration_json(self) -> dict[str, Any]: """ - Convert the matcher to an integration JSON object. + Convert the generator to an integration JSON object. - This method is used internally to convert the matcher to a JSON object - which can be embedded directly in a number of places in the Pact FFI. - - For more information about this format, see the docs: - - > https://docs.pact.io/implementation_guides/rust/pact_ffi/integrationjson - - Returns: - The matcher as an integration JSON object. + See + [`AbstractGenerator.to_integration_json`][pact.generate.generator.AbstractGenerator.to_integration_json] + for more information. """ @abstractmethod @@ -55,20 +49,17 @@ def to_generator_json(self) -> dict[str, Any]: This method is used internally to convert the generator to a JSON object which can be embedded directly in a number of places in the Pact FFI. - For more information about this format, see the docs: - - > https://github.com/pact-foundation/pact-specification/tree/version-4 - - and - - > https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers + For more information about this format, refer to the [Pact + specification](https://github.com/pact-foundation/pact-specification/tree/version-4) + and the [matchers + section](https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers) Returns: The generator as a generator JSON object. """ -class GenericGenerator(Generator): +class GenericGenerator(AbstractGenerator): """ Generic generator. @@ -108,17 +99,11 @@ def __init__( def to_integration_json(self) -> dict[str, Any]: """ - Convert the matcher to an integration JSON object. - - This method is used internally to convert the matcher to a JSON object - which can be embedded directly in a number of places in the Pact FFI. - - For more information about this format, see the docs: + Convert the generator to an integration JSON object. - > https://docs.pact.io/implementation_guides/rust/pact_ffi/integrationjson - - Returns: - The matcher as an integration JSON object. + See + [`AbstractGenerator.to_integration_json`][pact.generate.generator.AbstractGenerator.to_integration_json] + for more information. """ return { "pact:generator:type": self.type, @@ -129,19 +114,9 @@ def to_generator_json(self) -> dict[str, Any]: """ Convert the generator to a generator JSON object. - This method is used internally to convert the generator to a JSON object - which can be embedded directly in a number of places in the Pact FFI. - - For more information about this format, see the docs: - - > https://github.com/pact-foundation/pact-specification/tree/version-4 - - and - - > https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers - - Returns: - The generator as a generator JSON object. + See + [`AbstractGenerator.to_generator_json`][pact.generate.generator.AbstractGenerator.to_generator_json] + for more information. """ return { "type": self.type, diff --git a/src/pact/match/__init__.py b/src/pact/match/__init__.py index f80b067d0..5b98af334 100644 --- a/src/pact/match/__init__.py +++ b/src/pact/match/__init__.py @@ -261,7 +261,7 @@ from collections.abc import Mapping, Sequence from types import ModuleType - from pact.generate import Generator + from pact.generate import AbstractGenerator # ruff: noqa: A001 # We provide a more 'Pythonic' interface by matching the names of the @@ -541,7 +541,7 @@ def str( /, *, size: builtins.int | None = None, - generator: Generator | None = None, + generator: AbstractGenerator | None = None, ) -> AbstractMatcher[builtins.str]: """ Match a string value, optionally with a specific length. @@ -587,7 +587,7 @@ def string( /, *, size: builtins.int | None = None, - generator: Generator | None = None, + generator: AbstractGenerator | None = None, ) -> AbstractMatcher[builtins.str]: """ Alias for [`match.str`][pact.match.str]. @@ -938,7 +938,7 @@ def type( *, min: builtins.int | None = None, max: builtins.int | None = None, - generator: Generator | None = None, + generator: AbstractGenerator | None = None, ) -> AbstractMatcher[_T]: """ Match a value by type (primitive or complex). @@ -973,7 +973,7 @@ def like( *, min: builtins.int | None = None, max: builtins.int | None = None, - generator: Generator | None = None, + generator: AbstractGenerator | None = None, ) -> AbstractMatcher[_T]: """ Alias for [`match.type`][pact.match.type]. @@ -1016,7 +1016,7 @@ def includes( value: builtins.str, /, *, - generator: Generator | None = None, + generator: AbstractGenerator | None = None, ) -> AbstractMatcher[builtins.str]: """ Match a string that includes a given value. diff --git a/src/pact/match/matcher.py b/src/pact/match/matcher.py index b0fad9963..4d845df43 100644 --- a/src/pact/match/matcher.py +++ b/src/pact/match/matcher.py @@ -15,7 +15,7 @@ from json import JSONEncoder from typing import Any, Generic, TypeVar -from pact.generate.generator import Generator +from pact.generate.generator import AbstractGenerator from pact.types import UNSET, Matchable, MatcherType, Unset _T_co = TypeVar("_T_co", covariant=True) @@ -85,7 +85,7 @@ def __init__( type: MatcherType, # noqa: A002 /, value: _T_co | Unset = UNSET, - generator: Generator | None = None, + generator: AbstractGenerator | None = None, extra_fields: Mapping[str, Any] | None = None, **kwargs: Matchable, ) -> None: @@ -359,6 +359,6 @@ def default(self, o: Any) -> Any: # noqa: ANN401 """ if isinstance(o, AbstractMatcher): return o.to_integration_json() - if isinstance(o, Generator): + if isinstance(o, AbstractGenerator): return o.to_integration_json() return super().default(o) From 28338c7c335f864d2b4d669021ee75b9ee4c97d3 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 30 Sep 2025 08:49:44 +1000 Subject: [PATCH 6/8] docs: update for v3 and add migration guide Signed-off-by: JP-Ellis --- MIGRATION.md | 425 ++++++++++++++++++++++++++++++++++ docs/SUMMARY.md | 1 + docs/consumer.md | 528 ++++++++++++++++++++++--------------------- docs/provider.md | 356 ++++++++++++++++++++--------- docs/releases.md | 3 +- mkdocs.yml | 26 +-- src/pact/verifier.py | 15 +- 7 files changed, 968 insertions(+), 386 deletions(-) create mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 000000000..08c39cecd --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,425 @@ +# Migration Guide + +> [!INFO] +> +> This document is best viewed on the [Pact Python docs site](https://pact-foundation.github.io/pact-python/MIGRATION/). + +This document outlines the key changes and migration steps for users transitioning between major Pact Python versions. It focuses on breaking changes, new features, and best practices to ensure a smooth upgrade. + +## Migrating from `2.x` to `3.x` + +### Key Changes + +- Replaced the entire Pact CLI-based implementation with a brand new version leveraging the [core Pact FFI](https://github.com/pact-foundation/pact-reference) written in Rust. + - This will help ensure feature parity between different language implementations and improve performance and reliability. This also brings compatibility with the latest Pact Specification (v4). +- The bundled CLI is now a separate package: [`pact-python-cli`](https://pypi.org/project/pact-python-cli/). +- The programmatic API has been completely overhauled to be more Pythonic and easier to use. Legacy process calls and return code checks have been removed in favour of proper exception handling. +- The old programmatic interface is available in the `pact.v2` module for backwards compatibility. This is deprecated, will not receive new features, and will be removed in a future release. + +### Using the `pact.v2` Compatibility Module + +For teams with larger codebases that need time to fully migrate to the new v3 API, a backwards compatibility module is provided at `pact.v2`. This module contains the same API as Pact Python v2.x and serves as an interim measure to assist gradual migration. + +To use the v2 compatibility module, you must install pact-python with the `v2` feature enabled: + +```bash +pip install pact-python[v2] +``` + +All existing `pact.*` imports need to be updated to use `pact.v2.*` instead. Here are some common examples: + +```python +# Old v2.x imports +from pact import Consumer, Provider +from pact.matchers import Like, EachLike +from pact.verifier import Verifier + +# New v3.x imports using the v2 compatibility module +from pact.v2 import Consumer, Provider +from pact.v2.matchers import Like, EachLike +from pact.v2.verifier import Verifier +``` + +Please note that this compatibility module is intended as a temporary solution. A full migration to the new v3 API is strongly recommended as soon as feasible. The compatibility module will only receive critical bug fixes and no new features. + +Pact, by default, updates existing Pact contracts in place, so migrating consumer tests incrementally should be feasible. However, newer features (e.g., message pacts) will likely require a full migration, and mixed usage of v2 and v3 APIs is not officially supported. + +### Consumer Changes + +The v3 API introduces significant changes to how consumer tests are structured and written. The main changes simplify the API, making it more Pythonic and flexible. + +#### Defining a Pact + +The `Consumer` and `Provider` classes have been removed. Instead, a single `Pact` class is used to define the consumer-provider relationship: + +```python title="v2" +from pact.v2 import Consumer, Provider + +consumer = Consumer('my-web-front-end') +provider = Provider('my-backend-service') + +pact = consumer.has_pact_with(provider, pact_dir='/path/to/pacts') +``` + +```python title="v3" +from pact import Pact + +pact = Pact('my-web-front-end', 'my-backend-service') +``` + +#### Defining Interactions + +The v3 interface favours method chaining and provides more granular control over request and response definitions. + +```python title="v2" +( + pact + .given('user exists') + .upon_receiving('a request for user data') + .with_request( + 'GET', + '/users/123', + headers={'Accept': 'application/json'}, + query={'include': 'profile'} + ) + .will_respond_with( + 200, + headers={'Content-Type': 'application/json'}, + body={'id': 123, 'name': 'Alice'} + ) +) +``` + +```python title="v3" +( + pact + .upon_receiving('a request for user data') + .given('user exists', id=123, name='Alice') # (1) + .with_request('GET', '/users/123') + .with_header('Accept', 'application/json') + .with_query_parameter('include', 'profile') + .will_respond_with(200) + .with_header('Content-Type', 'application/json') + .with_body({'id': 123, 'name': 'Alice'}, content_type='application/json')) +``` + +1. The new provider states can be streamlined by parameterizing them directly in the `given()` method. So instead of defining multiple variations of a `"user exists"` state, you can define it once and pass different parameters as needed. These can be passed as keyword arguments to `given()`, or as a dictionary in the second positional argument. + +Some methods are shared across request and response definitions, such as `with_header()` and `with_body()`. Pact Python automatically applies them to the correct part of the interaction based on whether they are called before or after `will_respond_with()`. Alternatively, these methods accept an optional `part` argument to explicitly specify whether they apply to the request or response. + +#### Running Tests + +Pact Python v2 had two different ways to run consumer tests, both of which spawned a separate mock service process. The new v3 API provides a single, consistent way to run tests using the `serve()` method. + +```python title="v2 - with context manager" +pact = Consumer("my-consumer").has_pact_with( + Provider("my-provider"), + host_name="localhost", + port=1234, +) + +# Context manager automatically calls setup() and verify() +with pact: + response = requests.get(pact.uri + '/users/123') +# Pact file written automatically on exit +``` + +```python title="v2 - with manual service management" +pact = Consumer("my-consumer").has_pact_with( + Provider("my-provider"), + host_name="localhost", + port=1234, +) + +# Manually start the mock service +pact.start_service() +pact.setup() # Configure interactions + +# Make requests +response = requests.get(pact.uri + '/users/123') +# Assertions... + +# Verify and stop +pact.verify() # Writes pact file +pact.stop_service() +``` + +The new API entirely replaces both of these approaches with a single, consistent method: + +```python title="v3" +pact = Pact("my-consumer", "my-provider") + +with pact.serve() as srv: + response = requests.get(f"{srv.url}/users/123") +``` + +The server host and port can be specified in `serve()` if needed, but by default, the server binds to `localhost` on a random available port. More details can be found in the [API reference][pact.pact.Pact.serve]. + +##### Writing Pact Files + +Since the old v2 API executed a sub-process for the mock service, the Pact file was automatically written when the context manager exited or when `pact.verify()` was called. The new v3 API runs the mock service in-process, so the Pact file must be written explicitly using the `write_file()` method: + +```python title="v2" +with pact: + # tests... +# Pact file written automatically +``` + +```python title="v3" +pact = Pact('consumer', 'provider') +# Define interactions and run tests... +pact.write_file('/path/to/pacts') +``` + +#### Matchers + +Support for matchers has been greatly expanded and improved in the v3 API. The older v2 classes defined a limited set of matchers, while the new API provides a more comprehensive and flexible approach. + +```python title="v2" +from pact.v2.matchers import Like, EachLike, Regex, Term + +# Usage: +Like({'id': 123}) +EachLike({'item': 'value'}) +Regex('hello world', r'^hello') +``` + +```python title="v3" +from pact import match + +# Usage: +match.like({'id': 123}) +match.each_like({'item': 'value'}) +match.regex('hello world', r'^hello') +``` + +For a full list of available matchers and their usage, refer to the [API documentation][pact.match]. + +### Provider Changes + +The provider verification API has been completely redesigned in v3 to provide a more intuitive and flexible interface. The old `Provider` and `Verifier` classes have been replaced by a single `Verifier` class with a fluent interface. + +#### Creating a Verifier + +```python title="v2" +from pact.v2 import Provider, Verifier + +# Create separate Provider and Verifier instances +provider = Provider('my-provider') +verifier = Verifier(provider, 'http://localhost:8080') +``` + +```python title="v3" +from pact import Verifier + +# Single Verifier instance with provider name +verifier = Verifier('my-provider') +``` + +The protocol specification is now done through the `add_transport` method, which allows for more flexible configuration and supports multiple transports if needed. + +```python title="v2" +verifier = Verifier(provider, 'http://localhost:8080') +``` + +```python title="v3" +verifier = ( + Verifier('my-provider') + .add_transport(url='http://localhost:8080') + # Or more granular control: + .add_transport( + protocol='http', + port=8080, + path='/api/v1', + scheme='https' + ) +) +``` + +#### Adding Pact Sources + +Support for both local files and Pact Brokers is retained in v3, with the `verify_pacts` and `verify_with_broker` methods replaced by a more flexible source configuration. This allows multiple sources to be combined, and selectors to be applied. + + + +=== "Local Files" + + ```python title="v2" + success, logs = verifier.verify_pacts( + './pacts/consumer1-provider.json', + './pacts/consumer2-provider.json' + ) + ``` + + ```python title="v3" + verifier = ( + Verifier('my-provider') + # It can discover all Pact files in a directory + .add_source('./pacts/') + # Or read individual files + .add_source('./pacts/specific-consumer.json') + ) + ``` + +=== "Pact Broker" + + ```python title="v2" + success, logs = verifier.verify_with_broker( + broker_url='https://pact-broker.example.com', + broker_username='username', + broker_password='password' + ) + ``` + + ```python title="v3" + verifier = ( + Verifier('my-provider') + .broker_source( + 'https://pact-broker.example.com', + username='username', + password='password' + ) + ) + + # Or with selectors for more control + broker_builder = ( + verifier + .broker_source( + 'https://pact-broker.example.com', + selector=True + ) + .include_pending() + .provider_branch('main') + .consumer_tags('main', 'develop') + .build() + ) + ``` + + The `selector=True` argument returns a [`BrokerSelectorBuilder`][pact.verifier.BrokerSelectorBuilder] instance, which provides methods to configure which pacts to fetch. The `build()` call finalizes the configuration and returns the `Verifier` instance which can then be further configured. + + + +#### Provider State Handling + +The old v2 API required the provider to expose an HTTP endpoint dedicated to handling provider states. This is still supported in v3, but there are now more flexible options, allowing Python functions (or mappings of state names to functions) to be used instead. + + + +=== "URL-based State Handling" + + ```python title="v2" + success, logs = verifier.verify_pacts( + './pacts/consumer-provider.json', + provider_states_setup_url='http://localhost:8080/_pact/provider_states' + ) + ``` + + ```python title="v3" + # Option 1: URL-based (similar to v2) + verifier = ( + Verifier('my-provider') + .add_transport(url='http://localhost:8080') + .state_handler( + 'http://localhost:8080/_pact/provider_states', + body=True # (1) + ) + .add_source('./pacts/') + ) + ``` + + 1. The `body` argument specifies whether to use a `POST` request and pass information in the body, or to use a `GET` request and pass information through HTTP headers. For more details, see the [`state_handler` API documentation][pact.verifier.Verifier.state_handler]. + +=== "Functional State Handling" + + ```python title="v2" + # Not supported + ``` + + ```python title="v3 - Function" + def handler(name, params=None): + if name == 'user exists': + # Set up user in database/mock + create_user(params.get('id', 123)) + elif name == 'no users exist': + # Clear users + clear_users() + + verifier = ( + Verifier('my-provider') + .add_transport(url='http://localhost:8080') + .state_handler(handler) + .add_source('./pacts/') + ) + ``` + + ```python title="v3 - Mapping" + state_handlers = { + 'user exists': lambda name, params: create_user(params.get('id', 123)), + 'no users exist': lambda name, params: clear_users(), + } + + verifier = ( + Verifier('my-provider') + .add_transport(url='http://localhost:8080') + .state_handler(state_handlers) + .add_source('./pacts/') + ) + ``` + + More information on the state handler function signature can be found in the [`state_handler` API documentation][pact.verifier.Verifier.state_handler]. By default, the handlers only _set up_ the provider state. If you need to also _tear down_ the state after verification, you can use the `teardown=True` argument to enable this behaviour. + + !!! warning + + These functions run in the test process, so any side effects must be properly shared with the provider. If using mocking libraries, ensure the provider is started in a separate thread of the same process (using `threading.Thread` or similar), rather than a separate process (e.g., using `multiprocessing.Process` or `subprocess.Popen`). + + + +#### Message Verification + +Message verification is now much more straightforward in v3, with a a similar interface to HTTP verification and fixes a number of issues and deficiencies present in the v2 implementation (including the swapped behaviour of `expects_to_receive` and `given`, and the lack of support for matchers and generators). + +```python title="v3 - Functional Handler" +def message_handler(description, metadata): + if description == 'user created event': + return { + 'id': 123, + 'name': 'Alice', + 'event': 'created' + } + elif description == 'user deleted event': + return {'id': 123, 'event': 'deleted'} + +verifier = ( + Verifier('my-provider') + .message_handler(message_handler) + .add_source('./pacts/') +) +``` + +```python title="v3 - Dictionary Mapping" +messages = { + 'user created event': {'id': 123, 'name': 'Alice', 'event': 'created'}, + 'user deleted event': lambda desc, meta: {'id': 123, 'event': 'deleted'} +} + +verifier = ( + Verifier('my-provider') + .message_handler(messages) + .add_source('./pacts/') +) +``` + +#### Running Verification + +Verification has been simplified and no longer requires checking return codes. Instead, the `verify()` method raises an exception on failure, or returns normally on success. + +```python title="v2" +success, logs = verifier.verify_pacts('./pacts/consumer-provider.json') +if not success: + print(logs) + raise AssertionError("Verification failed!") +``` + +```python title="v3" +verifier.verify() +``` diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index f86c2df59..99021f73d 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -4,6 +4,7 @@ - [Consumer](consumer.md) - [Provider](provider.md) - [Releases](releases.md) + - [Migration Guide](MIGRATION.md) - [Changelog](CHANGELOG.md) - [Contributing](CONTRIBUTING.md) - [Examples](examples/) diff --git a/docs/consumer.md b/docs/consumer.md index b145ded0f..c42967119 100644 --- a/docs/consumer.md +++ b/docs/consumer.md @@ -1,356 +1,358 @@ # Consumer Testing -Pact is a consumer-driven contract testing tool. This means that the consumer specifies the expected interactions with the provider, and these interactions are used to create a contract. This contract is then used to verify that the provider meets the consumer's expectations. +Pact is a consumer-driven contract testing tool. The consumer specifies the expected interactions with the provider, which are used to create a contract. This contract is then used to verify that the provider meets the consumer's expectations. + + +
+ +```mermaid +sequenceDiagram + box Consumer Side + participant Consumer + participant P1 as Pact + end + box Provider Side + participant P2 as Pact + participant Provider + end + Consumer->>P1: GET /users/123 + P1->>Consumer: 200 OK + Consumer->>P1: GET /users/999 + P1->>Consumer: 404 Not Found + + P1--)P2: Pact Broker + + P2->>Provider: GET /users/123 + Provider->>P2: 200 OK + P2->>Provider: GET /users/999 + Provider->>P2: 404 Not Found +``` + +
+ + +The consumer is the client that makes requests, and the provider is the server that responds. In most cases, the consumer is a front-end application and the provider is a back-end service; however, a back-end service may also require information from another service, making it a consumer of that service. -The consumer is the client that makes requests, and the provider is the server that responds to those requests. In most straightforward cases, the consumer is a front-end application and the provider is a back-end service; however, a back-end service may also require information from another service, making it a consumer of that service. +The core logic is implemented in Rust and exposed to Python through the [core Pact FFI](https://github.com/pact-foundation/pact-reference). This will help ensure feature parity between different language implementations and improve performance and reliability. This also brings compatibility with the latest Pact Specification (v4). + +> [!NOTE] +> +> For asynchronous interactions (e.g., message queues), the consumer refers to the service that processes the messages. This is not covered here, but further information is available in the [Message Pact](https://docs.pact.io/getting_started/how_pact_works#non-http-testing-message-pact) section of the Pact documentation. ## Writing the Test -For an illustrative example, consider a simple API client that fetches user data from a service. The client might look like this: +> [!NOTE] +> +> The code below is an abridged version of [this example](./examples/http/requests_and_fastapi/README.md). + +### Consumer Client + +For example, consider a simple API client that interacts with a user provider service. The client has methods to get, create, and delete users. The user data model is defined using a dataclass. ```python +from dataclasses import dataclass +from datetime import datetime +from typing import Any import requests +@dataclass() +class User: # (1) + id: int + name: str + created_on: datetime class UserClient: - def __init__( - self, - base_url: str = "https://example.com/api/v1", - ): - self.base_url = base_url - - def get_user(self, user_id) -> dict[str, str | int | list[str]]: - """ - Fetch a user's data. - - Args: - user_id: The user's ID. - - Returns: - The user's data as a dictionary. It should have the following keys: - - - id: The user's ID. - - username: The user's username. - - groups: A list of groups the user belongs to. - """ - return requests.get("/".join([self.base_url, "user", user_id])).json() + """Simple HTTP client for interacting with a user provider service.""" + + def __init__(self, hostname: str) -> None: # (2) + self._hostname = hostname + + def get_user(self, user_id: int) -> User: + """Get a user by ID from the provider.""" + response = requests.get(f"{self._hostname}/users/{user_id}") + response.raise_for_status() + data: dict[str, Any] = response.json() + return User( # (3) + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), + ) ``` -The Pact test for this client would look like this: +1. The `User` dataclass represents the user data model _as used by the client_. Importantly, this is not necessarily the same as the data model used by the provider. The Pact contract should reflect what the consumer needs, not what the provider actually implements. -```python -import atexit -import unittest - -from user_client import UserClient -from pact import Consumer, Provider - -pact = Consumer("UserConsumer").has_pact_with(Provider("UserProvider")) -pact.start_service() -atexit.register(pact.stop_service) - - -class GetUserData(unittest.TestCase): - def test_get_user(self) -> None: - expected = { - "username": "UserA", - "id": 123, - "groups": ["Editors"], - } - - ( - pact.given("User 123 exists") - .upon_receiving("a request for user 123") - .with_request("get", "/user/123") - .will_respond_with(200, body=expected) - ) +2. The initialiser for the `UserClient` class takes a `hostname` parameter, which is the base URL of the user provider service. This ensures that the client can be easily pointed to the mock service during testing. - client = UserClient(pact.uri) - - with pact: - result = client.get_user(123) - self.assertEqual(result, expected) -``` +3. Only the fields required by the consumer are included in the `User` dataclass. The provider might return additional fields (e.g., `email`, `last_login`, etc.), but this consumer does not need to know about them and therefore they are ignored in the client implementation. -This test does the following: +### Consumer Test -- defines the Consumer and Provider objects that describe the product and the service under test, -- uses `given` to define the setup criteria for the Provider, and -- defines the expected request and response for the interaction. +The following is a Pact test for the `UserClient` class defined above. It sets up a mock provider, defines the expected interactions, and verifies that the client behaves as expected. -The mock service is started when the `pact` object is used as a context manager. The `UserClient` object is created with the URI of the mock service, and the `get_user` method is called. The mock service responds with the expected data, and the test asserts that the response matches the expected data. +```python +from pathlib import Path + +import pytest +from pact import Pact, match + +@pytest.fixture +def pact() -> Generator[Pact, None, None]: # (1) + """Set up a Pact mock provider for consumer tests.""" + pact = Pact("user-consumer", "user-provider").with_specification("V4") # (2) + yield pact + pact.write_file(Path(__file__).parent / "pacts") + +def test_get_user(pact: Pact) -> None: + """Test the GET request for a user.""" + response: dict[str, object] = { # (3) + "id": match.int(123), + "name": match.str("Alice"), + "created_on": match.datetime(), + } + ( + pact.upon_receiving("A user request") # (4) + .given("the user exists", id=123, name="Alice") # (5) + .with_request("GET", "/users/123") # (6) + .will_respond_with(200) # (7) + .with_body(response, content_type="application/json") # (8) + ) - + with pact.serve() as srv: # (9) + client = UserClient(str(srv.url)) # (10) + user = client.get_user(123) + assert user.name == "Alice" +``` -!!! info +1. A [Pytest fixture](https://docs.pytest.org/en/stable/explanation/fixtures.html) provides a reusable `pact` object for multiple tests. In this case, the fixture creates a [`Pact`][pact.Pact] instance representing the contract between the consumer and provider. The fixture yields the `pact` object to the test function, and after the test completes, writes the generated pact file to the specified directory. - A common mistake is to use a generic HTTP client to make requests to the mock service. This defeats the purpose of the test as it does not verify that the client is making the correct requests and handling the responses correctly. +2. The Pact specification version is set to `"V4"` to ensure compatibility with the latest features and improvements in the Pact ecosystem. Note that this is the default version, so this line is optional unless you want to specify a different version. - +3. The expected response is defined using the `match` module for flexible matching of the response data. Here, the `id` field is expected to be an integer, the `name` field a string, and the `created_on` field a datetime string. The specific values are not important, as long as they match the expected types. -An alternative to using the `pact` object as a context manager is to manually call the `setup` and `verify` methods: +4. The `upon_receiving` method defines the description of the interaction. This description also uniquely identifies the interaction within the Pact file. -```python -with pact: - result = client.get_user(123) - self.assertEqual(result, expected) +5. The `given` method sets up the provider state, indicating that the user with ID 123 exists. Pact allows parameters to be passed to the provider state, which can be used to set up the provider in a specific way. Here, the parameters `id=123` and `name="Alice"` are provided, which the provider can use to create the user if necessary. -# Is equivalent to +6. The `with_request` method defines the expected request that the consumer will make. Here, it specifies that a `GET` request will be made to the `/users/123` endpoint. -pact.setup() -result = client.get_user(123) -self.assertEqual(result, expected) -pact.verify() -``` +7. The `will_respond_with` method specifies the expected HTTP status code of the response. Here, a `200 OK` status is expected. The `will_respond_with` method also helps separate the request definition from the response definition, improving readability. -## Mock Service +8. The `with_body` method defines the expected body of the response, using the `response` dictionary defined earlier. The `content_type` parameter specifies that the response will be in JSON format. -Pact provides a mock service that simulates the provider service based on the defined interactions. The mock service is started when the `pact` object is used as a context manager, or when the `setup` method is called. +9. The `pact.serve()` method starts the mock service, and the `srv` object provides the URL of the mock service. Within this context, any requests made to the mock service will be handled according to the interactions defined on the `pact` object. Once the context is exited, the mock service is stopped, and the interactions are verified to ensure all expected requests were made. -The mock service is started by default on `localhost:1234`, but you can adjust this during Pact creation. This is particularly useful if the consumer interactions with multiple services. +10. The `UserClient` is instantiated with this URL, and the `get_user` method is called to retrieve the user data. The test asserts that the returned user's name is "Alice". -```python -pact = Consumer('Consumer').has_pact_with( - Provider('Provider'), - host_name='mockservice', - port=8080, -) -``` +The test begins with a Pytest fixture that creates a reusable Pact instance representing the contract between `"user-consumer"` and `"user-provider"`. The expected response is defined using flexible matchers (`match.int()`, `match.str()`, `match.datetime()`) to validate data types rather than exact values, making the test more robust against varying response data. -The mock service offers you several important features when building your contracts: +The interaction definition includes a description, provider state parameters, request details, and expected response format. Only the required parts of the interaction are specified, rather than an exhaustive specification. For example, the client will typically add additional headers (e.g., `User-Agent`, `Accept`, etc.) to the request, but these are not necessary for the contract and are therefore omitted. Similarly, the provider's response may include additional fields or headers that the consumer will ignore, so these are also not included in the contract. -- It provides a real HTTP server that your code can contact during the test and provides the responses you defined. -- You provide it with the expectations for the request your code will make and it will assert the contents of the actual requests made based on your expectations. -- If a request is made that does not match one you defined or if a request from your code is missing it will return an error with details. -- Finally, it will record your contracts as a JSON file that you can store in your repository or publish to a Pact broker. + The `pact.serve()` context manager starts a mock provider service that handles requests according to the defined interactions, creating a controlled testing environment. The actual client code is then executed against this mock service, ensuring it makes correct requests and handles responses properly. Once the context is exited, the Pact file is automatically written to the specified directory for later provider verification, completing the consumer-driven contract testing cycle. -## Requests +> [!WARNING] +> +> A common mistake is to use a generic HTTP client (e.g., `requests`, `httpx`, etc.) to make requests to the mock service within the test. This defeats the purpose of the test, as it does not verify that the client is making the correct requests and handling the responses correctly. -The expected request in the example above is defined with the `with_request` method. It is possible to customize the request further by specifying the method, path, body, headers, and query with the `method`, `path`, `body`, `headers` and `query` keyword arguments. +### Multi-Interaction Testing -- Adding query parameters: +The mock service can handle multiple interactions within a single test. This is useful when you want to test a sequence of requests and responses. For example, a first request might create a background task, a second request might check the status of that task, and a final request retrieves the result. This flow can be tested in a single test function by defining multiple interactions on the `pact` object: - ```python - pact.with_request( - path="/user/search", - query={"group": "editor"}, - ) - ``` +```python +( + pact.upon_receiving("A request to create a task") + .with_request("POST", "/tasks", body={"type": "long_running"}) + .will_respond_with(202) + .with_header("Location", "/tasks/1/status") +) -- Using different HTTP methods: +( + pact.upon_receiving("A request to check task status") + .with_request("GET", "/tasks/1/status") + .will_respond_with(200) + .with_body({"status": "completed"}) + .with_headers({ + "Task-ID": "1", + "Location": "/tasks/1/result", + }) +) - ```python - pact.with_request( - method="DELETE", - path="/user/123", - ) - ``` +( + pact.upon_receiving("A request to get task result") + .with_request("GET", "/tasks/1/result") + .will_respond_with(200) + .with_body({"result": "Task completed successfully"}) +) +``` -- Adding a request body and headers: +When the mock service is started with `pact.serve()`, it will handle requests for all defined interactions, ensuring the client code can be tested against a realistic sequence of operations. Furthermore, for the test to pass, all defined interactions must be exercised by the client code. If any interaction is not used, the test will fail. - ```python - pact.with_request( - method="POST", - path="/user/123", - body={"username": "UserA"}, - headers={"Content-Type": "application/json"}, - ) - ``` +## Mock Service -You can define exact values for your expected request like the examples above, or you can use the matchers defined later to assist in handling values that are variable. +Pact provides a mock service that simulates the provider service based on the defined interactions. The mock service is started when the `pact` object is used as a context manager with `pact.serve()`, as shown in the [consumer test](#consumer-test) example above. -It is important to note that the code you are testing _must_ complete all requests defined. Similarly, if a client makes a request that is not defined in the contract, the test will also fail. +The mock service automatically selects a random free port by default, helping to avoid port conflicts when running multiple tests. You can optionally specify a custom host and port during Pact creation if needed for your testing environment. -## Pattern Matching +```python +with pact.serve(host="localhost", port=1234) as srv: + client = UserClient(str(srv.url)) + user = client.get_user(123) +``` -Simple equality checks might be sufficient for simple requests, but more realistic tests will require more flexible matching. For example, the above scenario works great if the user information is always static, but will fail if the user has a datetime field that is regularly updated. +The mock service offers several important features when building your contracts: -In order to handle variable data and make tests more robust, there are a number of matchers available as described below. +- It provides a real HTTP server that your code can contact during the test and returns the responses you defined. +- You provide the expectations for the requests your code will make, and it asserts the contents of the actual requests made against your expectations. +- If a request is made that does not match one you defined, or if a request from your code is missing, it returns an error with details. -### Terms +## Broker -The `Term` matcher allows you to define a regular expression that the value should match, along with an example value. The pattern is used by Pact for determining the validity of the response, while the example value is returned by Pact in cases where a response needs to be generated. +The above example showed how to test a single consumer; however, without also testing the provider, the test is incomplete. The Pact Broker is a service that allows you to share and manage your contracts between your consumer and provider tests. It acts as a central repository for your contracts, allowing you to publish contracts from your consumer tests and retrieve them in your provider tests. -This is useful when you need to assert that a value has a particular format, but you are unconcerned about the exact value. +Once the tests are complete (and successful), the contracts can be uploaded to the Pact Broker. The provider can then download the contracts and run its own tests to ensure it meets the consumer's expectations. -```python -body = { - "id": 123, - "reference": Term(r"[A-Z]\d{3,6}-[0-9a-f]{6}", "X1234-456def"), - "last_modified": Term( - r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", - "2024-07-20T13:27:03Z", - ), -} +The Broker CLI is a command-line tool that can be installed through the `pact-python-cli` package, or directly from the [Pact Standalone](https://github.com/pact-foundation/pact-standalone) releases page. It bundles several standalone CLI tools, including the `pact-broker` CLI client. -( - pact.given("User 123 exists") - .upon_receiving("a request for user 123") - .with_request("get", "/user/123") - .will_respond_with(200, body=body, headers={ - "X-Request-ID": Term( - r"[a-z]{4}[0-9]{8}-[A-Z]{3}", - "abcd1234-EFG", - ), - }) -) - -client = UserClient(pact.uri) +The general syntax for the CLI is: -with pact: - result = client.get_user(123) - assert result["id"] == 123 - assert result["reference"] == "X1234-456def" - assert result["last_modified"] == "2024-07-20T13:27:03Z" +```console +pact-broker publish \ + /path/to/pacts/consumer-provider.json \ + --consumer-app-version 1.0.0 \ + --auto-detect-version-properties ``` -In this example, the `UserClient` must include a `X-Request-ID` header matching the pattern (irrespective of the actual value), and the mock service will respond with the example values. +It expects the following environment variables to be set: -### Like +`PACT_BROKER_BASE_URL` -The `Like` matcher asserts that the element's type matches the matcher. If the mock service needs to produce an answer, the example value provided will be returned. Some examples of the `Like` matcher are: +: The base URL of the Pact Broker (e.g., `https://test.pactflow.io` if using [PactFlow](https://pactflow.io), or the URL to your self-hosted Pact Broker instance). -```python -from pact import Like +`PACT_BROKER_USERNAME` / `PACT_BROKER_PASSWORD` -Like(123) # Requires any integer -Like("hello world") # Requires any string -Like(3.14) # Requires any float -Like(True) # Requires any boolean -``` +: The username and password for authenticating with the Pact Broker. -More complex object can be defined, in which case the `Like` matcher will be applied recursively: +`PACT_BROKER_TOKEN` -```python -from pact import Like, Term - -Like({ - 'id': 123, # Requires any integer - "reference": Term(r"[A-Z]\d{3,6}-[0-9a-f]{6}", "X1234-456def"), - 'confirmed': False, # Requires any boolean - 'address': { # Requires a dictionary - 'street': '200 Bourke St' # Requires any string - } -}) -``` +: An alternative to using username and password, this is a token that can be used for authentication (e.g., used with [PactFlow](https://pactflow.io)). -### EachLike +## Pattern Matching -The `EachLike` matcher asserts the value is an array type that consists of elements like the one passed in. It can be used to assert simple arrays, +Simple equality checks work for basic scenarios, but realistic tests need flexible matching to handle variable data such as timestamps, IDs, and dynamic content. The `match` module provides matchers that validate data structure and types rather than exact values. ```python -from pact import EachLike - -EachLike(1) # All items are integers -EachLike('hello') # All items are strings -``` +from pact import match -or other matchers can be nested inside to assert more complex objects +# Instead of exact matches that break easily: +response = { + "id": 12345, # Brittle - specific value + "email": "user@example.com", # Fails if email changes + "created_at": "2024-01-15T10:30:00Z" # Breaks on different timestamps +} -```python -from pact import EachLike, Term -EachLike({ - 'username': Term('[a-zA-Z]+', 'username'), - 'id': 123, - 'groups': EachLike('administrators') -}) +# Use flexible matchers: +response = { + "id": match.int(12345), # Any integer + "email": match.regex("user@example.com", regex=r".+@.+\..+"), + "created_at": match.datetime("2024-01-15T10:30:00Z") +} ``` -> Note, you do not need to specify everything that will be returned from the Provider in a JSON response, any extra data that is received will be ignored and the tests will still pass. +Common matcher types include: - +- **Type matchers**: `match.int()`, `match.str()`, `match.bool()` - validate data types +- **Pattern matchers**: `match.regex()`, `match.uuid()` - validate specific formats +- **Collection matchers**: `match.each_like()`, `match.array_containing()` - handle arrays and objects +- **Date/time matchers**: `match.date()`, `match.time()`, `match.datetime()` - flexible timestamp handling -> Note, to get the generated values from an object that can contain matchers like Term, Like, EachLike, etc. for assertion in self.assertEqual(result, expected) you may need to use get_generated_values() helper function: +Matchers ensure your contracts focus on data structure and semantics rather than brittle exact values, making tests more robust and maintainable. -```python -from pact.matchers import get_generated_values -self.assertEqual(result, get_generated_values(expected)) -``` +For comprehensive documentation and examples, see the [API Reference](api/match/README.md) and the [`match` module documentation][pact.match]. For more about Pact's matching specification, see [Matching](https://docs.pact.io/getting_started/matching). -### Common Formats +## Dynamic Data Generation -As you have seen above, regular expressions are a powerful tool for matching complex patterns; however, they can be cumbersome to write and maintain. A number of common formats have been predefined for ease of use: +While matchers validate that received data conforms to expected patterns, generators produce realistic test data for responses. The `generate` module provides functions to create dynamic values that change on each test run, making your Pact contracts more realistic and robust. -| matcher | description | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `identifier` | Match an ID (e.g. 42) | -| `integer` | Match all numbers that are integers (both ints and longs) | -| `decimal` | Match all real numbers (floating point and decimal) | -| `hexadecimal` | Match all hexadecimal encoded strings | -| `date` | Match string containing basic ISO8601 dates (e.g. 2016-01-01) | -| `timestamp` | Match a string containing an RFC3339 formatted timestamp (e.g. Mon, 31 Oct 2016 15:21:41 -0400) | -| `time` | Match string containing times in ISO date format (e.g. T22:44:30.652Z) | -| `iso_datetime` | Match string containing ISO 8601 formatted dates (e.g. 2015-08-06T16:53:10+01:00) | -| `iso_datetime_ms` | Match string containing ISO 8601 formatted dates, enforcing millisecond precision (e.g. 2015-08-06T16:53:10.123+01:00) | -| `ip_address` | Match string containing IP4 formatted address | -| `ipv6_address` | Match string containing IP6 formatted address | -| `uuid` | Match strings containing UUIDs | +```python +from pact import generate -These can be used to replace other matchers +# Instead of static values in your mock responses +response = { + "user_id": 123, # Always the same + "session_token": "abc-def-123", # Predictable + "created_at": "2024-07-20T14:30:00+00:00" # Never changes +} -```python -from pact import Like, Format - -Like({ - 'id': Format().integer, - 'lastUpdated': Format().timestamp, - 'location': { - 'host': Format().ip_address - }, -}) +# Use generators for dynamic, realistic data +response = { + "user_id": generate.int(min=1, max=999999), + "session_token": generate.uuid(), + "created_at": generate.datetime("%Y-%m-%dT%H:%M:%S%z") +} ``` -For more information see [Matching](https://docs.pact.io/getting_started/matching) +Generators are particularly useful when: -## Broker +- **Testing with fresh data**: Each test run uses different values, helping catch issues with data handling +- **Avoiding test pollution**: Dynamic IDs and tokens prevent tests from accidentally depending on specific values +- **Simulating real conditions**: Generated timestamps, UUIDs, and random numbers better represent actual API behavior +- **Provider state integration**: Using `generate.provider_state()` to inject values from the provider's test setup -The above example showed how to test a single consumer; however, without also testing the provider, the test is incomplete. The Pact Broker is a service that allows you to share your contracts between your consumer and provider tests. +### Common Generators -The Pact Broker acts as a central repository for all your contracts and verification results, and provides a number of features to help you get the most out of your Pact workflow. +```python +from pact import generate -Once the tests are complete, the contracts can be uploaded to the Pact Broker. The provider can then download the contracts and run its own tests to ensure it meets the consumer's expectations. There are two ways to upload contracts as shown below. +response = { + # Numeric values with constraints + "user_id": generate.int(min=1, max=999999), + "price": generate.float(precision=2), # 2 total digits + "hex_color": generate.hex(digits=6), # 6-digit hex code -### Broker CLI (_recommended_) + # String and text data + "username": generate.str(size=8), # 8-character string + "confirmation": generate.regex(r"[A-Z]{3}-\d{4}"), # Pattern-based -The Broker CLI is a command-line tool that is bundled with the Pact Python package. It can be used to publish contracts to the Pact Broker. See [Publishing and retrieving pacts](https://docs.pact.io/pact_broker/publishing_and_retrieving_pacts) + # Identifiers + "session_id": generate.uuid(), # Standard UUID format + "simple_id": generate.uuid(format="simple"), # No hyphens -The general syntax for the CLI is: + # Dates and times + "created_at": generate.datetime("%Y-%m-%dT%H:%M:%S%z"), + "birth_date": generate.date("%Y-%m-%d"), + "start_time": generate.time("%H:%M:%S"), -```console -pact-broker publish \ - /path/to/pacts/consumer-provider.json \ - --consumer-app-version 1.0.0 \ - --branch main \ - --broker-base-url https://test.pactflow.io \ - --broker-username someUsername \ - --broker-password somePassword -``` + # Boolean values + "is_active": generate.bool(), -If the broker requires a token, you can use the `--broker-token` flag instead of `--broker-username` and `--broker-password`. + # Provider-specific values + "server_url": generate.mock_server_url(), + "dynamic_value": generate.provider_state("${expression}") +} +``` -### Python API +### Combining Matchers and Generators -If you wish to use a more programmatic approach within Python, it is possible to use the `Broker` class to publish contracts to the Pact Broker. Note that it is ultimately a wrapper around the CLI, and as a result, the CLI is recommended for most use cases. +Matchers and generators work together to create flexible, realistic contracts. Use matchers to validate incoming data and generators to produce dynamic response data: ```python -broker = Broker(broker_base_url="http://localhost") -broker.publish( - "TestConsumer", - "2.0.1", - branch="consumer-branch", - pact_dir=".", -) +# Request validation with matchers +request_body = { + "email": match.regex("user@example.com", regex=r".+@.+\..+"), + "age": match.int(25, min=18, max=100), + "preferences": match.array_containing([match.str("notifications")]) +} + +# Response generation with dynamic data +response_body = { + "id": generate.int(min=100000, max=999999), + "email": match.str("user@example.com"), # Echo back the input + "verification_token": generate.uuid(), # Fresh token each time + "created_at": generate.datetime("%Y-%m-%dT%H:%M:%S%z"), + "profile_url": generate.mock_server_url( + example="/profiles/12345", + regex=r"/profiles/\d+" + ) +} ``` -The parameters for this differ slightly in naming from their CLI equivalents: - -| CLI | native Python | -| ---------------------------------- | -------------------------------- | -| `--branch` | `branch` | -| `--build-url` | `build_url` | -| `--auto-detect-version-properties` | `auto_detect_version_properties` | -| `--tag=TAG` | `consumer_tags` | -| `--tag-with-git-branch` | `tag_with_git_branch` | -| `PACT_DIRS_OR_FILES` | `pact_dir` | -| `--consumer-app-version` | `version` | -| `n/a` | `consumer_name` | +This approach ensures your tests validate the correct data structures while generating realistic, varied response data that better simulates real-world API behaviour. diff --git a/docs/provider.md b/docs/provider.md index 6145f9a47..6344331dc 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -1,158 +1,298 @@ # Provider Testing -Pact is a consumer-driven contract testing tool. This means that the consumer specifies the expected interactions with the provider, and these interactions are used to create a contract. This contract is then used to verify that the provider behaves as expected. +Pact is a consumer-driven contract testing tool. The consumer specifies the expected interactions with the provider, which are used to create a contract. This contract is then used to verify that the provider behaves as expected. + + +
+ +```mermaid +sequenceDiagram + box Consumer Side + participant Consumer + participant P1 as Pact + end + box Provider Side + participant P2 as Pact + participant Provider + end + Consumer->>P1: GET /users/123 + P1->>Consumer: 200 OK + Consumer->>P1: GET /users/999 + P1->>Consumer: 404 Not Found + + P1--)P2: Pact Broker + + P2->>Provider: GET /users/123 + Provider->>P2: 200 OK + P2->>Provider: GET /users/999 + Provider->>P2: 404 Not Found +``` -The provider verification process works by replaying the interactions from the consumer against the provider and checking that the responses match what was expected. This is done by using the Pact files created by the consumer tests, either by reading them from a local filesystem, or by fetching them from a Pact Broker. +
+ -## Verifying Pacts +The provider verification process works by replaying the interactions from the consumer against the provider and checking that the responses match what was expected. This is done using the Pact files created by the consumer tests, either by reading them from the local file system or by fetching them from a Pact Broker. -### Command Line Interface +The core verification logic is implemented in Rust and exposed to Python through the [core Pact FFI](https://github.com/pact-foundation/pact-reference). This will help ensure feature parity between different language implementations and improve performance and reliability. This also brings compatibility with the latest Pact Specification (v4). -Pact Python comes bundled[^1] with the `pact-verifier` CLI tool to verify your provider. It is located at within the `{site-packages}/pact/bin` directory, and the following command will add it to your path: +## Verifying Pacts -[^1]: The CLI is available for most architecture, but if you are on a platform where the CLI is not bundled, you can install the [Pact Ruby Standalone](https://github.com/pact-foundation/pact-ruby-standalone) release. +Pact Python's [`Verifier`][pact.verifier.Verifier] class provides the mechanism to fetch and verify Pacts against your provider application, while also facilitating provider state management and result publishing. - +### Basic Usage -=== "Linux / macOS (`sh`)" +You can verify Pacts from a local directory as follows: - ```bash - site_packages=$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))') - if [ -d "$sit_p_packages/pact/bi ]; then]; then - export PATH_p$site_packages/pact/bin:$P - else - echo "Pact CLI not found." - fi - ``` +```python +from pact import Verifier -=== "Windows (`pwsh`)" +def test_provider(): + """Test the provider against the consumer contract.""" + verifier = ( + Verifier("my-provider") # Provider name + .add_source("./pacts/") # Directory containing Pact files + .add_transport(url="http://localhost:8080") # Provider URL + ) - ```pwsh - $sitePackages = (python -c 'import sysconfig; print(sysconfig.get_path("purelib"))') - if (Test-Path "$sitePackages/pact/bin") { - $env:PATH += ";$sitePackages/pact/bin" - } else { - Write-Host "Pact CLI not found." - } - ``` + verifier.verify() +``` - +The `Verifier` inspects the specified directory for Pact files matching the provider name, and verifies each interaction against the running provider at the given URL. -You can verify that the CLI is available by running: +### Verifying from a Pact Broker -```console -pact-verifier --help +Although local Pact files are useful for quick tests, in most cases you will want to verify Pacts from a Pact Broker. In this case, specify the broker URL and any necessary authentication: + +```python +from pact import Verifier + +def test_provider_from_broker(): + """Test the provider against contracts from a Pact Broker.""" + verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .broker_source( + "https://my-broker.example.com", + username="broker-username", # or use token="bearer-token" + password="broker-password", + ) + ) + + verifier.verify() ``` -A minimal invocation of the Pact verifier looks like this: +For advanced broker configurations, use the selector builder pattern to filter which Pacts to verify: -```console -pact-verifier ./pacts/ \ - --provider-base-url=http://localhost:8080 +```python +from pact import Verifier + +def test_provider_with_selectors(): + """Test with advanced broker selectors.""" + verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .broker_source( + "https://my-broker.example.com", + token="bearer-token", + selector=True, # Enable selector builder + ) + .include_pending() # Include pending pacts + .include_wip_since("2023-01-01") # Include WIP pacts since date + .provider_tags("main", "develop") + .consumer_tags("production", "main") + .build() # Build the selector + ) + + verifier.verify() ``` -This will verify all the Pacts in the `./pacts/` directory against the provider located at `http://localhost:8080`. +More information on the selector options is available in the [API reference][pact.verifier.BrokerSelectorBuilder]. -#### Options +### Publishing Results -| Option | Description | -| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--provider-base-url TEXT` | Base URL of the provider to verify against. [required] | -| `--provider-states-setup-url TEXT` | URL to send POST requests to setup a given provider state. | -| `--pact-broker-username TEXT` | Username for Pact Broker basic authentication. Can also be specified via the environment variable PACT_BROKER_USERNAME. | -| `--pact-broker-url TEXT` | Base URl for the Pact Broker instance to publish pacts to. Can also be specified via the environment variable PACT_BROKER_BASE_URL. | -| `--consumer-version-tag TEXT` | Retrieve the latest pacts with this consumer version tag. Used in conjunction with --provider. May be specified multiple times. | -| `--consumer-version-selector TEXT` | Retrieve the latest pacts with this consumer version selector. Used in conjunction with --provider. May be specified multiple times. | -| `--provider-version-tag TEXT` | Tag to apply to the provider application version. May be specified multiple times. | -| `--provider-version-branch TEXT` | The name of the branch the provider version belongs to. | -| `--pact-broker-password TEXT` | Password for Pact Broker basic authentication. Can also be specified via the environment variable PACT_BROKER_PASSWORD. | -| `--pact-broker-token TEXT` | Bearer token for Pact Broker authentication. Can also be specified via the environment variable PACT_BROKER_TOKEN. | -| `--provider TEXT` | Retrieve the latest pacts for this provider. | -| `--custom-provider-header TEXT` | Header to add to provider state set up and pact verification requests. eg 'Authorization: Basic cGFjdDpwYWN0'. May be specified multiple times. | -| `-t`, `--timeout INTEGER` | The duration in seconds we should wait to confirm that the verification process was successful. Defaults to 30. | -| `-a`, `--provider-app-version TEXT` | The provider application version. Required for publishing verification results. | -| `-r`, -`-publish-verification-results` | Publish verification results to the broker. | -| `--verbose` / `--no-verbose` | Toggle verbose logging, defaults to False. | -| `--log-dir TEXT` | The directory for the pact.log file. | -| `--log-level TEXT` | The logging level. | -| `--enable-pending` / `--no-enable-pending` | Allow pacts which are in pending state to be verified without causing the overall task to fail. For more information, see [`pact.io/pending`](https://pact.io/pending) | -| `--include-wip-pacts-since TEXT` | Automatically include the pending pacts in the verification step. For more information, see [WIP pacts](https://docs.pact.io/pact_broker/advanced_topics/wip_pacts/) | -| `--help` | Show this message and exit. | +To publish verification results to the Broker: - +```python +verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .broker_source("https://my-broker.example.com", token="bearer-token") +) -??? note "Deprecated Options" +if "CI" in os.environ: + verifier.set_publish_options( # (1) + version="1.2.3", + branch="main", + tags=["production"], + ) - | Option | Description | - | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - | `--pact-url TEXT` | specify pacts as arguments instead. The URI of the pact to verify. Can be an HTTP URI, a local file or directory path. It can be specified multiple times to verify several pacts. | - | `--pact-urls TEXT` | specify pacts as arguments instead. The URI(s) of the pact to verify. Can be an HTTP URI(s) or local file path(s). Provide multiple URI separated by a comma. | - | `--provider-states-url TEXT` | URL to fetch the provider states for the given provider API. | +verifier.verify() +``` - +1. While we use static values here, in practice, you would use dynamic values taken from your CI/CD environment, or helper function to extract version information from your source control system. -### Python API +## Configuration Options -Pact Python also provides a pythonic wrapper around the command line interface, allowing you to use the Pact verifier directly from your Python code. This can be useful if you want to integrate the verifier into your test suite or CI/CD pipeline. +### Filtering -To use the Python API, you need to import the `Verifier` class from the `pact` module: +Filter interactions to verify: ```python -verifier = Verifier( - provider='UserService', - provider_base_url="http://localhost:8080", +verifier = ( + Verifier("my-provider") + .filter(description="user.*", state="user exists") # Regex filters + .filter_consumers("mobile-app", "web-app") # Specific consumers only ) ``` -If you are verifying Pacts from the local filesystem, you can use the `verify_pacts` method: - -```python -success, logs = verifier.verify_pacts('./userserviceclient-userservice.json') -assert success == 0 -``` +### Custom Headers -On the other hand, if you are using a Pact Broker, you can use the `verify_with_broker` method: +While the Pact contract should define all necessary request and response details, there are cases where you may need to add custom headers to every request made to the provider during verification (e.g., for authentication). ```python -success, logs = verifier.verify_with_broker( - broker_url=PACT_BROKER_URL, - # Auth options +verifier = ( + Verifier("my-provider") + .add_custom_header("Authorization", "Bearer token123") + .add_custom_headers({ + "X-Debug-Mode": "true", + "X-Debug-Secret": "123-abc", + }) ) -assert success == 0 ``` -Where the auth options can either be `broker_username` and `broker_password` for OSS Pact Broker, or `broker_token` for PactFlow. +## Provider States + +Provider states are a crucial concept in Pact testing. When a consumer creates a Pact, it specifies not just what request to make, but also what state the provider should be in when that request is made. This is expressed using the `.given(...)` method in consumer tests. -The CLI options are available as keyword arguments to the various methods of the `Verifier` class: +For example, if a consumer test includes `given("user 123 exists")`, it means the provider must have a user with ID 123 in its system when the interaction is verified. A better approach is to parameterise the provider state instead of hard-coding values within the state name, such as `given("user exists", id=123, name="Alice")`. -| CLI | native Python | -| -------------------------------- | ------------------------------ | -| `--log-dir` | `log_dir` | -| `--log-level` | `log_level` | -| `--provider-app-version` | `provider_app_version` | -| `--headers` | `custom_provider_headers` | -| `--consumer-version-tag` | `consumer_tags` | -| `--provider-version-tag` | `provider_tags` | -| `--provider-states-setup-url` | `provider_states_setup_url` | -| `--verbose` | `verbose` | -| `--consumer-version-selector` | `consumer_selectors` | -| `--publish-verification-results` | `publish_verification_results` | -| `--provider-version-branch` | `provider_version_branch` | +For these provider states to be meaningful, the provider tests need to set up the appropriate state before each interaction is verified. This is done using state handler methods. Optionally, these handlers can also perform teardown actions after the interaction is verified, which is useful for cleaning up test data. -You can see more details in the examples +### State Handler Methods -- [Message Provider Verifier Test](https://github.com/pact-foundation/pact-python/blob/main/examples/tests/test_03_message_provider.py) -- [Flask Provider Verifier Test](https://github.com/pact-foundation/pact-python/blob/main/examples/tests/test_01_provider_flask.py) -- [FastAPI Provider Verifier Test](https://github.com/pact-foundation/pact-python/blob/main/examples/tests/test_01_provider_fastapi.py) +The `Verifier` class provides three ways to handle provider states: -## Provider States +1. **Function-based handlers** - A single function handles all states +2. **Dictionary-based handlers** - Map state names to specific functions +3. **URL-based handlers** - External HTTP endpoint manages states -In general, the consumer will make a request to the provider under the assumption that the provider has certain data, or is in a certain state. This is expressed in the consumer side through the `.given(...)` method. For example, `given("user 123 exists")` assumes that the provider knows about a user with the ID 123. +> [!WARNING] +> +> If using mocking libraries, the function- and dictionary-based handlers must run in the same process as the provider application. For example, using `threading.Thread` to run the provider in a separate thread of the same process is acceptable, but using `multiprocessing.Process` to run the provider in a separate process will not work. -To support this, the provider needs to be able to set up the state of the provider to match the expected state of the consumer. This is done through the `--provider-states-setup-url` option, which is a URL that the verifier will call to set up the provider state. +### Function-Based State Handler -Managing the provider state is an important part of the provider testing process, and the best way to manage it will depend on your application. A couple of options include: +A single function can handle all provider states: -1. Having an endpoint is part of the provider application, but not active in production. A call to this endpoint will set up the provider state, typically by [mocking][unittest.mock] the data store or external services. This method is used in the examples above. +```python +from pact import Verifier +from typing import Literal, Any + +def handle_provider_state( + state: str, + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, +) -> None: + """Handle all provider state changes.""" + parameters = parameters or {} + if state == "user exists": + if action == "setup": + return create_user( + parameters.get("id", 123), + name=parameters.get("name", "Alice"), + ) + if action == "teardown": + return delete_user(parameters.get("id", 123)) + + if state == "no users exist": + if action == "setup": + return clear_all_users() + + msg = f"Unknown state/action: {state}/{action}" + raise ValueError(msg) + +verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .add_source("./pacts/") + .state_handler(handle_provider_state, teardown=True) +) + +verifier.verify() +``` + +### Dictionary-Based State Handler (Recommended) + +Map specific state names to dedicated handler functions: + +```python +from pact import Verifier +from typing import Literal, Any + +def mock_user_exists( + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, +) -> None: + """Mock the provider state where a user exists.""" + parameters = parameters or {} + user_id = parameters.get("id", 123) + + if action == "setup": + # Set up the user in your test database/mock + return UserDb.create(User( + id=user_id, + name=parameters.get("name", "Test User"), + email=parameters.get("email", "test@example.com"), + )) + if action == "teardown": + # Clean up after the test + return UserDb.delete(user_id) + +def mock_user_does_not_exist( + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, +) -> None: + """Mock the provider state where a user does not exist.""" + parameters = parameters or {} + user_id = parameters.get("id", 123) + + if action == "setup" and user_id: + # Ensure the user doesn't exist + if UserDb.get(user_id): + UserDb.delete(user_id) + +# Map state names to handler functions +state_handlers = { + "user exists": mock_user_exists, + "user 123 exists": mock_user_exists, + "user does not exist": mock_user_does_not_exist, +} + +verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .add_source("./pacts/") + .state_handler(state_handlers, teardown=True) +) + +verifier.verify() +``` + +### URL-Based State Handler + +This approach relies on the provider exposing an HTTP endpoint to manage provider states. This can be necessary if the handler logic cannot be implemented in Python (for example, if the provider is written in a different language). + +```python +verifier = ( + Verifier("my-provider") + .add_transport(url="http://localhost:8080") + .add_source("./pacts/") + .state_handler( + "http://localhost:8080/_pact/setup", # Your state setup endpoint + teardown=True, + body=True, # Send state info in request body + ) +) +``` -2. A separate application that has access to the same data store as the provider. This application can be started and stopped with different data store states. +The state setup endpoint should handle POST requests with the state information if `body=True` is set; otherwise, the state information will be passed through query parameters and headers. diff --git a/docs/releases.md b/docs/releases.md index aac85519d..ba8d34996 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -13,6 +13,7 @@ There are a couple of exceptions to the [semantic versioning](https://semver.org - Dropping support for a Python version is not considered a breaking change and is not necessarily accompanied by a major version bump. - Private APIs are not considered part of the public API and are not subject to the same rules as the public API. They can be changed at any time without a major version bump. Private APIs are denoted by a leading underscore in the name. Please be aware that the distinction between public and private APIs will be made concrete from version 3 onwards, and best judgement is used in the meantime to determine what is public and what is private. - Deprecations are not considered breaking changes and are not necessarily accompanied by a major version bump. Their removal is considered a breaking change and is accompanied by a major version bump. +- Changes to the type annotations will not be considered breaking changes, unless they are accompanied by a change to the runtime behaviour. Any deviation from the the standard semantic versioning rules will be clearly documented in the release notes. @@ -34,7 +35,7 @@ In order to reduce the build time, the pipeline builds different sets of wheels | Trigger | Platforms | Wheels | | ------------ | ----------------- | --------- | | Tag | `x86_64`, `arm64` | all | -| `main` | `x86_64` | all | +| `main` | `x86_64` | all | | Pull Request | `x86_64` | `cp312-*` | ### Publish Step diff --git a/mkdocs.yml b/mkdocs.yml index b5a825d3a..724b8f617 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,24 +11,16 @@ hooks: - docs/scripts/rewrite-docs-links.py plugins: - - search - - literate-nav: - nav_file: SUMMARY.md - - section-index - - gh-admonitions - gen-files: scripts: - docs/scripts/markdown.py - docs/scripts/python.py # - docs/scripts/other.py - - llmstxt: - full_output: llms-full.txt - sections: - Usage documentation: - - api/*.md - - api/**/*.md - Examples: - - examples/*.md + - search + - literate-nav: + nav_file: SUMMARY.md + - section-index + - gh-admonitions - mkdocstrings: default_handler: python enable_inventory: true @@ -72,6 +64,14 @@ plugins: annotations_path: brief show_signature: true show_signature_annotations: true + - llmstxt: + full_output: llms-full.txt + sections: + Usage documentation: + - api/*.md + - api/**/*.md + Examples: + - examples/*.md - social - blog: blog_toc: true diff --git a/src/pact/verifier.py b/src/pact/verifier.py index 7fa57b705..aab82ac1e 100644 --- a/src/pact/verifier.py +++ b/src/pact/verifier.py @@ -1161,6 +1161,15 @@ def broker_source( """ Adds a broker source to the verifier. + By default, or if `selector=False`, this function returns the verifier + instance to allow for method chaining. If `selector=True` is given, this + function returns a + [`BrokerSelectorBuilder`][pact.verifier.BrokerSelectorBuilder] instance + which allows for further configuration of the broker source in a fluent + interface. The [`build()`][pact.verifier.BrokerSelectorBuilder.build] + call is then used to finalise the broker source and return the verifier + instance for further configuration. + Args: url: The broker URL. The URL may contain a username and password for @@ -1180,7 +1189,11 @@ def broker_source( be specified through arguments, or embedded in the URL). selector: - Whether to return a BrokerSelectorBuilder instance. + Whether to return a + [BrokerSelectorBuilder][pact.verifier.BrokerSelectorBuilder] + instance. The builder instance allows for further configuration + of the broker source and must be finalised with a call to + [`build()`][pact.verifier.BrokerSelectorBuilder.build]. Raises: ValueError: From 272da45d6a6529ee80c4466944972d25aa5d0c99 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 3 Oct 2025 12:06:55 +1000 Subject: [PATCH 7/8] feat: populate broker source from env Allows the broker source information to be populated from environment variables. This still requires the `broker_source` method to be called: ```python ( Verifier("my-provider") # ... .broker_source() ) ``` This can be disabled by setting the new `use_env` argument to `False` (defaults to True), or by setting the relevant argument to `None` if you want to disable an input. Signed-off-by: JP-Ellis --- src/pact/verifier.py | 69 ++++++++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/src/pact/verifier.py b/src/pact/verifier.py index aab82ac1e..4be20dcb7 100644 --- a/src/pact/verifier.py +++ b/src/pact/verifier.py @@ -75,6 +75,7 @@ import json import logging +import os from collections.abc import Mapping from contextlib import nullcontext from datetime import date @@ -87,7 +88,7 @@ import pact_ffi from pact._server import MessageProducer, StateCallback from pact._util import apply_args -from pact.types import Message, MessageProducerArgs, StateHandlerArgs +from pact.types import UNSET, Message, MessageProducerArgs, StateHandlerArgs, Unset if TYPE_CHECKING: from collections.abc import Iterable @@ -1114,53 +1115,67 @@ def _add_source_remote( @overload def broker_source( self, - url: str | URL, + url: str | URL | Unset = UNSET, *, - username: str | None = None, - password: str | None = None, + username: str | Unset = UNSET, + password: str | Unset = UNSET, selector: Literal[False] = False, + use_env: bool = True, ) -> Self: ... @overload def broker_source( self, - url: str | URL, + url: str | URL | None | Unset = UNSET, *, - token: str | None = None, + token: str | None | Unset = UNSET, selector: Literal[False] = False, + use_env: bool = True, ) -> Self: ... @overload def broker_source( self, - url: str | URL, + url: str | URL | None | Unset = UNSET, *, - username: str | None = None, - password: str | None = None, + username: str | None | Unset = UNSET, + password: str | None | Unset = UNSET, selector: Literal[True], + use_env: bool = True, ) -> BrokerSelectorBuilder: ... @overload def broker_source( self, - url: str | URL, + url: str | URL | None | Unset = UNSET, *, - token: str | None = None, + token: str | None | Unset = UNSET, selector: Literal[True], + use_env: bool = True, ) -> BrokerSelectorBuilder: ... - def broker_source( + def broker_source( # noqa: PLR0913 self, - url: str | URL, + url: str | URL | None | Unset = UNSET, *, - username: str | None = None, - password: str | None = None, - token: str | None = None, + username: str | None | Unset = UNSET, + password: str | None | Unset = UNSET, + token: str | None | Unset = UNSET, selector: bool = False, + use_env: bool = True, ) -> BrokerSelectorBuilder | Self: """ Adds a broker source to the verifier. + If any of the values are `None`, the value will be read from the + environment variables unless the `use_env` parameter is set to `False`. + The known variables are: + + - `PACT_BROKER_BASE_URL` for the `url` parameter. + - `PACT_BROKER_USERNAME` for the `username` parameter. + - `PACT_BROKER_PASSWORD` for the `password` parameter. + - `PACT_BROKER_TOKEN` for the `token` parameter. + By default, or if `selector=False`, this function returns the verifier instance to allow for method chaining. If `selector=True` is given, this function returns a @@ -1195,22 +1210,40 @@ def broker_source( of the broker source and must be finalised with a call to [`build()`][pact.verifier.BrokerSelectorBuilder.build]. + use_env: + Whether to read missing values from the environment variables. + This is `True` by default which allows for easy configuration + from the standard Pact environment variables. In all cases, the + explicitly provided values take precedence over the environment + variables. + Raises: ValueError: If mutually exclusive authentication parameters are provided. """ + + def maybe_var(v: Any | Unset, env: str) -> str | None: # noqa: ANN401 + if isinstance(v, Unset): + return os.getenv(env) if use_env else None + return v + + url = maybe_var(url, "PACT_BROKER_BASE_URL") + if not url: + msg = "A broker URL must be provided" + raise ValueError(msg) url = URL(url) + username = maybe_var(username, "PACT_BROKER_USERNAME") if url.user and username: msg = "Cannot specify both `username` and a username in the URL" raise ValueError(msg) - username = url.user or username + password = maybe_var(password, "PACT_BROKER_PASSWORD") if url.password and password: msg = "Cannot specify both `password` and a password in the URL" raise ValueError(msg) - password = url.password or password + token = maybe_var(token, "PACT_BROKER_TOKEN") if token and (username or password): msg = "Cannot specify both `token` and `username`/`password`" raise ValueError(msg) From b64a4c4ec0b82ba3f04812abea8989e0af963896 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 3 Oct 2025 12:26:44 +1000 Subject: [PATCH 8/8] chore: clarify explanation of given Signed-off-by: JP-Ellis --- MIGRATION.md | 4 +++- src/pact/interaction/_base.py | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 08c39cecd..a79cfb628 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -103,7 +103,9 @@ The v3 interface favours method chaining and provides more granular control over .with_body({'id': 123, 'name': 'Alice'}, content_type='application/json')) ``` -1. The new provider states can be streamlined by parameterizing them directly in the `given()` method. So instead of defining multiple variations of a `"user exists"` state, you can define it once and pass different parameters as needed. These can be passed as keyword arguments to `given()`, or as a dictionary in the second positional argument. +1. In v2, there was limited support for parameterizing provider states, and each state variation often required a separate definition. For example, `given("user Alice exists with id 123")` and `given("user Bob exists with id 456")` would be two distinct states, which would then need to be handled separately in the provider state setup. + + The new interface can now define a common descriptor that can be reused with different parameters: `.given("user exists", id=123, name='Alice')` and `.given("user exists", id=456, name='Bob')`. This approach reduces redundancy and makes it easier to manage provider states. Some methods are shared across request and response definitions, such as `with_header()` and `with_body()`. Pact Python automatically applies them to the correct part of the interaction based on whether they are called before or after `will_respond_with()`. Alternatively, these methods accept an optional `part` argument to explicitly specify whether they apply to the request or response. diff --git a/src/pact/interaction/_base.py b/src/pact/interaction/_base.py index dfaa7a483..410fdfd35 100644 --- a/src/pact/interaction/_base.py +++ b/src/pact/interaction/_base.py @@ -151,8 +151,16 @@ def given( ``` This function can be called repeatedly to specify multiple provider - states for the same Interaction. If the same state is specified with - different parameters, then the parameters are merged together. + states for the same Interaction. This allows for the same provider state + to be reused with different parameters: + + ```python + ( + pact.upon_receiving("a request") + .given("a user exists", id=123, name="Alice") + .given("a user exists", id=456, name="Bob") + ) + ``` Args: state: @@ -170,11 +178,17 @@ def given( ) ``` + These parameters are merged with any additional keyword + arguments passed to the function. + kwargs: The additional parameters for the provider state, specified as additional arguments to the function. The values must be serializable using Python's [`json.dumps`][json.dumps] function. + + These parameters are merged with any parameters passed in the + `parameters` positional argument. """ if not parameters and not kwargs: pact_ffi.given(self._handle, state)