From e77b5620d3fe1bd3eb7b05a4553fdd7d83b6713d Mon Sep 17 00:00:00 2001 From: valkolovos Date: Tue, 13 Aug 2024 09:17:16 -0600 Subject: [PATCH 01/18] adding matcher POC --- examples/tests/v3/basic_flask_server.py | 109 +++++++++++++++++++ examples/tests/v3/test_matchers.py | 48 ++++++++ src/pact/v3/interaction/_base.py | 10 +- src/pact/v3/interaction/_http_interaction.py | 18 ++- src/pact/v3/matchers/__init__.py | 22 ++++ src/pact/v3/matchers/matchers.py | 68 ++++++++++++ 6 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 examples/tests/v3/basic_flask_server.py create mode 100644 examples/tests/v3/test_matchers.py create mode 100644 src/pact/v3/matchers/__init__.py create mode 100644 src/pact/v3/matchers/matchers.py diff --git a/examples/tests/v3/basic_flask_server.py b/examples/tests/v3/basic_flask_server.py new file mode 100644 index 000000000..3722825cf --- /dev/null +++ b/examples/tests/v3/basic_flask_server.py @@ -0,0 +1,109 @@ +import logging +import re +import signal +import subprocess +import sys +import time +from contextlib import contextmanager +from pathlib import Path +from threading import Thread +from typing import Generator, NoReturn + +import requests +from flask import Flask, Response, make_response +from yarl import URL + +logger = logging.getLogger(__name__) + +@contextmanager +def start_provider() -> Generator[URL, None, None]: # noqa: C901 + """ + Start the provider app. + """ + process = subprocess.Popen( # noqa: S603 + [ + sys.executable, + Path(__file__), + ], + cwd=Path.cwd(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + + pattern = re.compile(r" \* Running on (?P[^ ]+)") + while True: + if process.poll() is not None: + logger.error("Provider process exited with code %d", process.returncode) + logger.error( + "Provider stdout: %s", process.stdout.read() if process.stdout else "" + ) + logger.error( + "Provider stderr: %s", process.stderr.read() if process.stderr else "" + ) + msg = f"Provider process exited with code {process.returncode}" + raise RuntimeError(msg) + if ( + process.stderr + and (line := process.stderr.readline()) + and (match := pattern.match(line)) + ): + break + time.sleep(0.1) + + url = URL(match.group("url")) + logger.debug("Provider started on %s", url) + for _ in range(50): + try: + response = requests.get(str(url / "_test" / "ping"), timeout=1) + assert response.text == "pong" + break + except (requests.RequestException, AssertionError): + time.sleep(0.1) + continue + else: + msg = "Failed to ping provider" + raise RuntimeError(msg) + + def redirect() -> NoReturn: + while True: + if process.stdout: + while line := process.stdout.readline(): + logger.debug("Provider stdout: %s", line.strip()) + if process.stderr: + while line := process.stderr.readline(): + logger.debug("Provider stderr: %s", line.strip()) + + thread = Thread(target=redirect, daemon=True) + thread.start() + + try: + yield url + finally: + process.send_signal(signal.SIGINT) + +if __name__ == "__main__": + app = Flask(__name__) + + @app.route("/path/to/") + def hello_world(test_id: int) -> Response: + response = make_response( + { + "response": { + "id": test_id, + "regex": "must end with 'hello world'", + "integer": 42, + "include": "hello world", + "minMaxArray": [1.0, 1.1, 1.2], + } + } + ) + response.headers["SpecialHeader"] = "Special: Hi" + return response + + @app.get("/_test/ping") + def ping() -> str: + """Simple ping endpoint for testing.""" + return "pong" + + app.run() diff --git a/examples/tests/v3/test_matchers.py b/examples/tests/v3/test_matchers.py new file mode 100644 index 000000000..477adeb22 --- /dev/null +++ b/examples/tests/v3/test_matchers.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from examples.tests.v3.basic_flask_server import start_provider +from pact.v3 import Pact, Verifier, matchers + + +def test_matchers() -> None: + pact_dir = Path(Path(__file__).parent.parent.parent / "pacts") + pact = Pact("consumer", "provider").with_specification("V4") + ( + pact.upon_receiving("a request") + .given("a state") + .with_request( + "GET", + matchers.regex("/path/to/100", r"/path/to/\d+", generator="Regex") + ) + .will_respond_with(200) + .with_body( + { + "response": matchers.like( + { + "regex": matchers.regex( + "must end with 'hello world'", r".*hello world'$" + ), + "integer": matchers.integer(42), + "include": matchers.include("hello world", "world"), + "minMaxArray": matchers.each_like( + matchers.decimal(1.0), + min_count=3, + max_count=5, + ), + }, + min_count=1, + ) + } + ) + .with_header( + "SpecialHeader", matchers.regex("Special: Foo", r"Special: \w+") + ) + ) + pact.write_file(pact_dir, overwrite=True) + with start_provider() as url: + verifier = ( + Verifier() + .set_info("My Provider", url=url) + .add_source(pact_dir / "consumer-provider.json") + ) + verifier.verify() diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index d597da59f..aa0ec1798 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any, Literal, overload import pact.v3.ffi +from pact.v3.matchers import Matcher, MatcherEncoder if TYPE_CHECKING: from pathlib import Path @@ -245,7 +246,7 @@ def given( def with_body( self, - body: str | None = None, + body: str | dict | Matcher | None = None, content_type: str | None = None, part: Literal["Request", "Response"] | None = None, ) -> Self: @@ -266,11 +267,16 @@ def with_body( If `None`, then the function intelligently determines whether the body should be added to the request or the response. """ + if not isinstance(body, str): + body_str = json.dumps(body, cls=MatcherEncoder) + else: + body_str = body + pact.v3.ffi.with_body( self._handle, self._parse_interaction_part(part), content_type, - body, + body_str, ) return self diff --git a/src/pact/v3/interaction/_http_interaction.py b/src/pact/v3/interaction/_http_interaction.py index 66d70bcdf..e19465461 100644 --- a/src/pact/v3/interaction/_http_interaction.py +++ b/src/pact/v3/interaction/_http_interaction.py @@ -4,11 +4,13 @@ from __future__ import annotations +import json from collections import defaultdict from typing import TYPE_CHECKING, Iterable, Literal import pact.v3.ffi from pact.v3.interaction._base import Interaction +from pact.v3.matchers import Matcher, MatcherEncoder if TYPE_CHECKING: try: @@ -94,7 +96,7 @@ def _interaction_part(self) -> pact.v3.ffi.InteractionPart: """ return self.__interaction_part - def with_request(self, method: str, path: str) -> Self: + def with_request(self, method: str, path: str | Matcher) -> Self: """ Set the request. @@ -106,13 +108,17 @@ def with_request(self, method: str, path: str) -> Self: path: Path for the request. """ - pact.v3.ffi.with_request(self._handle, method, path) + if isinstance(path, Matcher): + path_str = json.dumps(path, cls=MatcherEncoder) + else: + path_str = path + pact.v3.ffi.with_request(self._handle, method, path_str) return self def with_header( self, name: str, - value: str, + value: str | dict | Matcher, part: Literal["Request", "Response"] | None = None, ) -> Self: r""" @@ -208,12 +214,16 @@ def with_header( name_lower = name.lower() index = self._request_indices[(interaction_part, name_lower)] self._request_indices[(interaction_part, name_lower)] += 1 + if not isinstance(value, str): + value_str: str = json.dumps(value, cls=MatcherEncoder) + else: + value_str = value pact.v3.ffi.with_header_v2( self._handle, interaction_part, name, index, - value, + value_str, ) return self diff --git a/src/pact/v3/matchers/__init__.py b/src/pact/v3/matchers/__init__.py new file mode 100644 index 000000000..233716a11 --- /dev/null +++ b/src/pact/v3/matchers/__init__.py @@ -0,0 +1,22 @@ +from .matchers import ( # noqa: TID252 + Matcher, + MatcherEncoder, + decimal, + each_like, + include, + integer, + like, + regex, +) + +__all__ = [ + "decimal", + "each_like", + "integer", + "include", + "like", + "type", + "regex", + "Matcher", + "MatcherEncoder", +] diff --git a/src/pact/v3/matchers/matchers.py b/src/pact/v3/matchers/matchers.py new file mode 100644 index 000000000..68210ab62 --- /dev/null +++ b/src/pact/v3/matchers/matchers.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from json import JSONEncoder +from typing import Any, Dict, Optional, Union + + +class Matcher: + def __init__( + self, + matcher_type: str, + value: Optional[Any] = None, # noqa: ANN401 + generator: Optional[str] = None, + **kwargs: Optional[str | int | float | bool], + ) -> None: + self.type = matcher_type + self.value = value + self.generator = generator + self.extra_attrs = {k: v for k, v in kwargs.items() if v is not None} + + def to_json(self) -> Union[str, Dict[str, Any]]: + json_data: Dict[str, Any] = { + "pact:matcher:type": self.type, + } + if self.value is not None: + json_data["value"] = self.value + if self.generator is not None: + json_data["pact:generator:type"] = self.generator + [json_data.update({k: v}) for k, v in self.extra_attrs.items() if v is not None] + return json_data + + +class MatcherEncoder(JSONEncoder): + def default(self, obj: Any) -> Union[str, Dict[str, Any]]: # noqa: ANN401 + if isinstance(obj, Matcher): + return obj.to_json() + return super().default(obj) + + +def decimal(value: float) -> Matcher: + return Matcher("decimal", value) + + +def each_like( + value: Any, # noqa: ANN401 + min_count: Optional[int] = None, + max_count: Optional[int] = None, +) -> Matcher: + return Matcher("type", [value], min=min_count, max=max_count) + + +def include(value: str, include: str) -> Matcher: + return Matcher("include", value, include=include) + + +def integer(value: int) -> Matcher: + return Matcher("integer", value) + + +def like( + value: Any, # noqa: ANN401 + min_count: Optional[int] = None, + max_count: Optional[int] = None, +) -> Matcher: + return Matcher("type", value, min=min_count, max=max_count) + + +def regex(value: str, regex: str, generator: Optional[str] = None) -> Matcher: + return Matcher("regex", value, regex=regex, generator=generator) From 18d0e122b9b465f76579d631676f3c3257338288 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Thu, 12 Sep 2024 16:46:58 -0600 Subject: [PATCH 02/18] Matcher / generator implementations and examples --- examples/tests/v3/basic_flask_server.py | 51 ++- examples/tests/v3/test_matchers.py | 133 ++++-- src/pact/v3/generators/__init__.py | 245 ++++++++++ src/pact/v3/interaction/_base.py | 2 +- src/pact/v3/interaction/_http_interaction.py | 14 +- src/pact/v3/matchers/__init__.py | 457 ++++++++++++++++++- src/pact/v3/matchers/matchers.py | 68 --- 7 files changed, 850 insertions(+), 120 deletions(-) create mode 100644 src/pact/v3/generators/__init__.py delete mode 100644 src/pact/v3/matchers/matchers.py diff --git a/examples/tests/v3/basic_flask_server.py b/examples/tests/v3/basic_flask_server.py index 3722825cf..9d7ec9bba 100644 --- a/examples/tests/v3/basic_flask_server.py +++ b/examples/tests/v3/basic_flask_server.py @@ -1,3 +1,7 @@ +""" +Simple flask server for matcher example. +""" + import logging import re import signal @@ -5,7 +9,9 @@ import sys import time from contextlib import contextmanager +from datetime import datetime from pathlib import Path +from random import randint, uniform from threading import Thread from typing import Generator, NoReturn @@ -15,6 +21,7 @@ logger = logging.getLogger(__name__) + @contextmanager def start_provider() -> Generator[URL, None, None]: # noqa: C901 """ @@ -82,22 +89,46 @@ def redirect() -> NoReturn: finally: process.send_signal(signal.SIGINT) + if __name__ == "__main__": app = Flask(__name__) @app.route("/path/to/") def hello_world(test_id: int) -> Response: - response = make_response( - { - "response": { - "id": test_id, - "regex": "must end with 'hello world'", - "integer": 42, - "include": "hello world", - "minMaxArray": [1.0, 1.1, 1.2], - } + response = make_response({ + "response": { + "id": test_id, + "regexMatches": "must end with 'hello world'", + "randomRegexMatches": + "1-8 digits: 12345678, 1-8 random letters abcdefgh", + "integerMatches": test_id, + "decimalMatches": round(uniform(0, 9), 3), # noqa: S311 + "booleanMatches": True, + "randomIntegerMatches": randint(1, 100), # noqa: S311 + "randomDecimalMatches": round(uniform(0, 9), 1), # noqa: S311 + "randomStringMatches": "hi there", + "includeMatches": "hello world", + "includeWithGeneratorMatches": "say 'hello world' for me", + "minMaxArrayMatches": [ + round(uniform(0, 9), 1) # noqa: S311 + for _ in range(randint(3, 5)) # noqa: S311 + ], + "arrayContainingMatches": [randint(1, 100), randint(1, 100)], # noqa: S311 + "dateMatches": "1999-12-31", + "randomDateMatches": "1999-12-31", + "timeMatches": "12:34:56", + "timestampMatches": datetime.now().isoformat(), # noqa: DTZ005 + "nullMatches": None, + "eachKeyMatches": { + "id_1": { + "name": "John Doe", + }, + "id_2": { + "name": "Jane Doe", + }, + }, } - ) + }) response.headers["SpecialHeader"] = "Special: Hi" return response diff --git a/examples/tests/v3/test_matchers.py b/examples/tests/v3/test_matchers.py index 477adeb22..e5f37f46d 100644 --- a/examples/tests/v3/test_matchers.py +++ b/examples/tests/v3/test_matchers.py @@ -1,7 +1,14 @@ +""" +Example test to show usage of matchers (and generators by extension). +""" + +import re from pathlib import Path +import requests + from examples.tests.v3.basic_flask_server import start_provider -from pact.v3 import Pact, Verifier, matchers +from pact.v3 import Pact, Verifier, generators, matchers def test_matchers() -> None: @@ -9,35 +16,107 @@ def test_matchers() -> None: pact = Pact("consumer", "provider").with_specification("V4") ( pact.upon_receiving("a request") - .given("a state") - .with_request( - "GET", - matchers.regex("/path/to/100", r"/path/to/\d+", generator="Regex") + .given("a state", parameters={"providerStateArgument": "providerStateValue"}) + .with_request("GET", matchers.regex(r"/path/to/\d{1,4}", "/path/to/100")) + .with_query_parameter( + "asOf", + matchers.like( + [ + matchers.date("yyyy-MM-dd", "2024-01-01"), + ], + min_count=1, + max_count=1, + ), ) .will_respond_with(200) - .with_body( - { - "response": matchers.like( - { - "regex": matchers.regex( - "must end with 'hello world'", r".*hello world'$" - ), - "integer": matchers.integer(42), - "include": matchers.include("hello world", "world"), - "minMaxArray": matchers.each_like( - matchers.decimal(1.0), - min_count=3, - max_count=5, - ), - }, - min_count=1, - ) - } - ) - .with_header( - "SpecialHeader", matchers.regex("Special: Foo", r"Special: \w+") - ) + .with_body({ + "response": matchers.like( + { + "regexMatches": matchers.regex( + r".*hello world'$", "must end with 'hello world'" + ), + "randomRegexMatches": matchers.regex( + r"1-8 digits: \d{1,8}, 1-8 random letters \w{1,8}" + ), + "integerMatches": matchers.integer(42), + "decimalMatches": matchers.decimal(3.1415), + "randomIntegerMatches": matchers.integer(min_val=1, max_val=100), + "randomDecimalMatches": matchers.decimal(digits=4), + "booleanMatches": matchers.boolean(value=False), + "randomStringMatches": matchers.string(size=10), + "includeMatches": matchers.includes("world"), + "includeWithGeneratorMatches": matchers.includes( + "world", generators.regex(r"\d{1,8} (hello )?world \d+") + ), + "minMaxArrayMatches": matchers.each_like( + matchers.number(digits=2), + min_count=3, + max_count=5, + ), + "arrayContainingMatches": matchers.array_containing([ + matchers.integer(1), + matchers.integer(2), + ]), + "dateMatches": matchers.date("yyyy-MM-dd", "2024-01-01"), + "randomDateMatches": matchers.date("yyyy-MM-dd"), + "timeMatches": matchers.time("HH:mm:ss", "12:34:56"), + "timestampMatches": matchers.timestamp( + "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "2024-01-01T12:34:56.000000" + ), + "nullMatches": matchers.null(), + "eachKeyMatches": matchers.each_key_matches( + { + "id_1": matchers.each_value_matches( + { + "name": matchers.string(size=30), + }, + rules=matchers.string("John Doe"), + ) + }, + rules=matchers.regex(r"id_\d+", "id_1"), + ), + }, + min_count=1, + ) + }) + .with_header("SpecialHeader", matchers.regex(r"Special: \w+", "Special: Foo")) ) + with pact.serve() as mockserver: + response = requests.get( + f"{mockserver.url}/path/to/35?asOf=2020-05-13", timeout=5 + ) + response_data = response.json() + # when a value is passed to a matcher, that value should be returned + assert ( + response_data["response"]["regexMatches"] == "must end with 'hello world'" + ) + assert response_data["response"]["integerMatches"] == 42 # noqa: PLR2004 + assert response_data["response"]["booleanMatches"] is False + assert response_data["response"]["includeMatches"] == "world" + assert response_data["response"]["dateMatches"] == "2024-01-01" + assert response_data["response"]["timeMatches"] == "12:34:56" + assert ( + response_data["response"]["timestampMatches"] + == "2024-01-01T12:34:56.000000" + ) + assert response_data["response"]["arrayContainingMatches"] == [1, 2] + assert response_data["response"]["nullMatches"] == "" + # when a value is not passed to a matcher, a value should be generated + random_regex_matcher = re.compile( + r"1-8 digits: \d{1,8}, 1-8 random letters \w{1,8}" + ) + assert random_regex_matcher.match( + response_data["response"]["randomRegexMatches"] + ) + random_integer = int(response_data["response"]["randomIntegerMatches"]) + assert random_integer >= 1 + assert random_integer <= 100 # noqa: PLR2004 + float(response_data["response"]["randomDecimalMatches"]) + assert ( + len(response_data["response"]["randomDecimalMatches"].replace(".", "")) == 4 # noqa: PLR2004 + ) + assert len(response_data["response"]["randomStringMatches"]) == 10 # noqa: PLR2004 + pact.write_file(pact_dir, overwrite=True) with start_provider() as url: verifier = ( diff --git a/src/pact/v3/generators/__init__.py b/src/pact/v3/generators/__init__.py new file mode 100644 index 000000000..0fe2f789e --- /dev/null +++ b/src/pact/v3/generators/__init__.py @@ -0,0 +1,245 @@ +""" +Generator implementations for pact-python. +""" + +from __future__ import annotations + +from typing import Any, Literal, Optional, Union + +type GeneratorTypeV3 = Literal[ + "RandomInt", + "RandomDecimal", + "RandomHexadecimal", + "RandomString", + "Regex", + "Uuid", + "Date", + "Time", + "DateTime", + "RandomBoolean", +] + +type GeneratorTypeV4 = Union[GeneratorTypeV3, Literal["ProviderState", "MockServerURL"]] + +type GeneratorTypes = Union[GeneratorTypeV3, GeneratorTypeV4] + + +class Generator: + """ + Generator interface for exporting. + """ + + +class ConcreteGenerator(Generator): + """ + ConcreteGenerator class. + + A generator is used to generate values for a field in a response. + """ + + def __init__( + self, + generator_type: GeneratorTypeV4, + extra_args: Optional[dict[str, Any]] = None, + ) -> None: + """ + Instantiate the generator class. + + Args: + generator_type (GeneratorTypeV4): + The type of generator to use. + extra_args (dict[str, Any], optional): + Additional configuration elements to pass to the generator. + """ + self.type = generator_type + self.extra_args = extra_args if extra_args is not None else {} + + def to_dict(self) -> str: + """ + Convert the generator to a dictionary for json serialization. + """ + data = { + "pact:generator:type": self.type, + } + data.update({k: v for k, v in self.extra_args.items() if v is not None}) + return data + + +def random_int( + min_val: Optional[int] = None, max_val: Optional[int] = None +) -> Generator: + """ + Create a random integer generator. + + Args: + min_val (Optional[int], optional): + The minimum value for the integer. + max_val (Optional[int], optional): + The maximum value for the integer. + """ + return ConcreteGenerator("RandomInt", {"min": min_val, "max": max_val}) + + +def random_decimal(digits: Optional[int] = None) -> Generator: + """ + Create a random decimal generator. + + Args: + digits (Optional[int], optional): + The number of digits to generate. + """ + return ConcreteGenerator("RandomDecimal", {"digits": digits}) + + +def random_hexadecimal(digits: Optional[int] = None) -> Generator: + """ + Create a random hexadecimal generator. + + Args: + digits (Optional[int], optional): + The number of digits to generate. + """ + return ConcreteGenerator("RandomHexadecimal", {"digits": digits}) + + +def random_string(size: Optional[int] = None) -> Generator: + """ + Create a random string generator. + + Args: + size (Optional[int], optional): + The size of the string to generate. + """ + return ConcreteGenerator("RandomString", {"size": size}) + + +def regex(regex: str) -> Generator: + """ + Create a regex generator. + + This will generate a string that matches the given regex. + + Args: + regex (str): + The regex pattern to match. + """ + return ConcreteGenerator("Regex", {"regex": regex}) + + +def uuid( + format_str: Optional[ + Literal["simple", "lower-case-hyphenated", "upper-case-hyphenated", "URN"] + ] = None, +) -> Generator: + """ + Create a UUID generator. + + Args: + format_str (Optional[Literal[]], optional): + The format of the UUID to generate. This parameter is only supported + under the V4 specification. + """ + return ConcreteGenerator("Uuid", {"format": format_str}) + + +def date(format_str: str) -> Generator: + """ + Create a date generator. + + This will generate a date string that matches the given format. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for more information on the format string. + + Args: + format_str (str): + The format string to use for the date. + """ + return ConcreteGenerator("Date", {"format": format_str}) + + +def time(format_str: str) -> Generator: + """ + Create a time generator. + + This will generate a time string that matches the given format. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for more information on the format string. + + Args: + format_str (str): + The format string to use for the time. + """ + return ConcreteGenerator("Time", {"format": format_str}) + + +def date_time(format_str: str) -> Generator: + """ + Create a date-time generator. + + This will generate a date-time string that matches the given format. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for more information on the format string. + + Args: + format_str (str): + The format string to use for the date-time. + """ + return ConcreteGenerator("DateTime", {"format": format_str}) + + +def random_boolean() -> Generator: + """ + Create a random boolean generator. + """ + return ConcreteGenerator("RandomBoolean") + + +def provider_state(expression: Optional[str] = None) -> Generator: + """ + Create a provider state generator. + + Generates a value that is looked up from the provider state context + using the given expression. + + Args: + expression (Optional[str], optional): + The expression to use to look up the provider state. + """ + return ConcreteGenerator("ProviderState", {"expression": expression}) + + +def mock_server_url( + regex: Optional[str] = None, example: Optional[str] = None +) -> Generator: + """ + Create a mock server URL generator. + + Generates a URL with the mock server as the base URL. + + Args: + regex (Optional[str], optional): + The regex pattern to match. + example (Optional[str], optional): + An example URL to use. + """ + return ConcreteGenerator("MockServerURL", {"regex": regex, "example": example}) + + +__all__ = [ + "Generator", + "GeneratorTypes", + "GeneratorTypeV3", + "GeneratorTypeV4", + "random_int", + "random_decimal", + "random_hexadecimal", + "random_string", + "regex", + "uuid", + "date", + "time", + "date_time", + "random_boolean", + "provider_state", + "mock_server_url", +] diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index aa0ec1798..410ea2e9a 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -267,7 +267,7 @@ def with_body( If `None`, then the function intelligently determines whether the body should be added to the request or the response. """ - if not isinstance(body, str): + if body and not isinstance(body, str): body_str = json.dumps(body, cls=MatcherEncoder) else: body_str = body diff --git a/src/pact/v3/interaction/_http_interaction.py b/src/pact/v3/interaction/_http_interaction.py index e19465461..14cd4833a 100644 --- a/src/pact/v3/interaction/_http_interaction.py +++ b/src/pact/v3/interaction/_http_interaction.py @@ -352,7 +352,7 @@ def set_headers( self.set_header(name, value, part) return self - def with_query_parameter(self, name: str, value: str) -> Self: + def with_query_parameter(self, name: str, value: str | dict | Matcher) -> Self: r""" Add a query to the request. @@ -412,17 +412,21 @@ def with_query_parameter(self, name: str, value: str) -> Self: """ index = self._parameter_indices[name] self._parameter_indices[name] += 1 + if not isinstance(value, str): + value_str: str = json.dumps(value, cls=MatcherEncoder) + else: + value_str = value pact.v3.ffi.with_query_parameter_v2( self._handle, name, index, - value, + value_str, ) return self def with_query_parameters( self, - parameters: dict[str, str] | Iterable[tuple[str, str]], + parameters: dict[str, str] | Iterable[tuple[str, str]] | Matcher, ) -> Self: """ Add multiple query parameters to the request. @@ -435,6 +439,10 @@ def with_query_parameters( parameters: Query parameters to add to the request. """ + if isinstance(parameters, Matcher): + matcher_dict = json.dumps(parameters, cls=MatcherEncoder) + for name, value in matcher_dict.items(): + self.with_query_parameter(name, value) if isinstance(parameters, dict): parameters = parameters.items() for name, value in parameters: diff --git a/src/pact/v3/matchers/__init__.py b/src/pact/v3/matchers/__init__.py index 233716a11..29d4967f1 100644 --- a/src/pact/v3/matchers/__init__.py +++ b/src/pact/v3/matchers/__init__.py @@ -1,22 +1,457 @@ -from .matchers import ( # noqa: TID252 - Matcher, - MatcherEncoder, - decimal, - each_like, - include, - integer, - like, - regex, +""" +Matcher implementations for pact-python. +""" +from __future__ import annotations + +from json import JSONEncoder +from typing import Any, Dict, List, Literal, Optional, Union, overload + +from pact.v3.generators import ( + Generator, + date_time, + random_boolean, + random_decimal, + random_int, + random_string, +) +from pact.v3.generators import ( + date as date_generator, ) +from pact.v3.generators import ( + regex as regex_generator, +) +from pact.v3.generators import ( + time as time_generator, +) + +type MatcherTypeV3 = Literal[ + "equality", + "regex", + "type", + "type", + "include", + "integer", + "decimal", + "number", + "timestamp", + "time", + "date", + "null", + "boolean", + "contentType", + "values", + "arrayContains", +] + +type MatcherTypeV4 = Union[ + MatcherTypeV3, + Literal[ + "statusCode", + "notEmpty", + "semver", + "eachKey", + "eachValue", + ], +] + +class Matcher: + """ + Matcher interface for exporting. + """ + +class ConcreteMatcher(Matcher): + """ + ConcreteMatcher class. + """ + + def __init__( + self, + matcher_type: MatcherTypeV4, + value: Optional[Any] = None, # noqa: ANN401 + generator: Optional[Generator] = None, + *, + force_generator: Optional[boolean] = False, + **kwargs: Optional[str | int | float | bool], + ) -> None: + """ + Initialize the matcher class. + + Args: + matcher_type (MatcherTypeV4): + The type of the matcher. + value (Any, optional): + The value to return when running a consumer test. + Defaults to None. + generator (Optional[Generator], optional): + The generator to use when generating the value. The generator will + generally only be used if value is not provided. Defaults to None. + force_generator (Optional[boolean], optional): + If True, the generator will be used to generate a value even if + a value is provided. Defaults to False. + **kwargs (Optional[str | int | float | bool], optional): + Additional configuration elements to pass to the matcher. + """ + self.type = matcher_type + self.value = value + self.generator = generator + self.force_generator = force_generator + self.extra_attrs = {k: v for k, v in kwargs.items() if v is not None} + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the matcher to a dictionary for json serialization. + """ + data: Dict[str, Any] = { + "pact:matcher:type": self.type, + } + data["value"] = self.value if self.value is not None else "" + if self.generator is not None and (self.value is None or self.force_generator): + data.update(self.generator.to_dict()) + [data.update({k: v}) for k, v in self.extra_attrs.items() if v is not None] + return data + + +class MatcherEncoder(JSONEncoder): + """ + Matcher encoder class for json serialization. + """ + + def default(self, obj: Any) -> Union[str, Dict[str, Any], List[Any]]: # noqa: ANN401 + """ + Encode the object to json. + """ + if isinstance(obj, Matcher): + return obj.to_dict() + return super().default(obj) + + +type MatchType = str | int | float | bool | dict | list | tuple | None | Matcher + + +def integer( + value: Optional[int] = None, + min_val: Optional[int] = None, + max_val: Optional[int] = None, +) -> Matcher: + """ + Returns a matcher that matches an integer value. + + Args: + value (int, optional): + The value to return when running a consumer test. Defaults to None. + min_val (int, optional): + The minimum value of the integer to generate. Defaults to None. + max_val (int, optional): + The maximum value of the integer to generate. Defaults to None. + """ + return ConcreteMatcher( + "integer", + value, + generator=random_int(min_val, max_val), + ) + + +def decimal(value: Optional[float] = None, digits: Optional[int] = None) -> Matcher: + """ + Returns a matcher that matches a decimal value. + + Args: + value (float, optional): + The value to return when running a consumer test. Defaults to None. + digits (int, optional): + The number of decimal digits to generate. Defaults to None. + """ + return ConcreteMatcher("decimal", value, generator=random_decimal(digits)) + + +@overload +def number( + value: Optional[int] = None, + min_val: Optional[int] = None, + max_val: Optional[int] = None, +) -> Matcher: ... + + +@overload +def number(value: Optional[float] = None, digits: Optional[int] = None) -> Matcher: ... + + +def number( + value: Optional[Union[int, float]] = None, + min_val: Optional[Union[int, float]] = None, + max_val: Optional[Union[int, float]] = None, + digits: Optional[int] = None, +) -> Matcher: + """ + Returns a matcher that matches a number value. + + Args: + value (int, float, optional): + The value to return when running a consumer test. + Defaults to None. + min_val (int, float, optional): + The minimum value of the number to generate. Only used when + value is an integer. Defaults to None. + max_val (int, float, optional): + The maximum value of the number to generate. Only used when + value is an integer. Defaults to None. + digits (int, optional): + The number of decimal digits to generate. Only used when + value is a float. Defaults to None. + """ + if isinstance(value, int): + generator = random_int(min_val, max_val) + else: + generator = random_decimal(digits) + return ConcreteMatcher("number", value, generator=generator) + + +def string( + value: Optional[str] = None, + size: Optional[int] = None, + generator: Optional[Generator] = None, +) -> Matcher: + """ + Returns a matcher that matches a string value. + + Args: + value (str, optional): + The value to return when running a consumer test. Defaults to None. + size (int, optional): + The size of the string to generate. Defaults to None. + generator (Optional[Generator], optional): + The generator to use when generating the value. Defaults to None. If + no generator is provided and value is not provided, a random string + generator will be used. + """ + if generator is not None: + return ConcreteMatcher("type", value, generator=generator, force_generator=True) + return ConcreteMatcher("type", value, generator=random_string(size)) + + +def boolean(*, value: Optional[bool] = True) -> Matcher: + """ + Returns a matcher that matches a boolean value. + + Args: + value (Optional[bool], optional): + The value to return when running a consumer test. Defaults to True. + """ + return ConcreteMatcher("boolean", value, generator=random_boolean()) + + +def date(format_str: str, value: Optional[str] = None) -> Matcher: + """ + Returns a matcher that matches a date value. + + Args: + format_str (str): + The format of the date. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for details on the format string. + value (str, optional): + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "date", value, format=format_str, generator=date_generator(format_str) + ) + + +def time(format_str: str, value: Optional[str] = None) -> Matcher: + """ + Returns a matcher that matches a time value. + + Args: + format_str (str): + The format of the time. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for details on the format string. + value (str, optional): + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "time", value, format=format_str, generator=time_generator(format_str) + ) + + +def timestamp(format_str: str, value: Optional[str] = None) -> Matcher: + """ + Returns a matcher that matches a timestamp value. + + Args: + format_str (str): + The format of the timestamp. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for details on the format string. + value (str, optional): + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "timestamp", + value, + format=format_str, + generator=date_time(format_str), + ) + + +def null() -> Matcher: + """ + Returns a matcher that matches a null value. + """ + return ConcreteMatcher("null") + + +def like( + value: MatchType, + min_count: Optional[int] = None, + max_count: Optional[int] = None, + generator: Optional[Generator] = None, +) -> Matcher: + """ + Returns a matcher that matches the given template. + + Args: + value (MatchType): + The template to match against. This can be a primitive value, a + dictionary, or a list and matching will be done by type. + min_count (int, optional): + The minimum number of items that must match the value. Defaults to None. + max_count (int, optional): + The maximum number of items that must match the value. Defaults to None. + generator (Optional[Generator], optional): + The generator to use when generating the value. Defaults to None. + """ + return ConcreteMatcher( + "type", + value, + min=min_count, + max=max_count, + generator=generator + ) + + +def each_like( + value: MatchType, + min_count: Optional[int] = 1, + max_count: Optional[int] = None, +) -> Matcher: + """ + Returns a matcher that matches each item in an array against a given value. + + Note that the matcher will validate the array length be at least one. + Also, the argument passed will be used as a template to match against + each item in the array and generally should not itself be an array. + + Args: + value (MatchType): + The value to match against. + min_count (int, optional): + The minimum number of items that must match the value. Default is 1. + max_count (int, optional): + The maximum number of items that must match the value. + """ + return ConcreteMatcher("type", [value], min=min_count, max=max_count) + + +def includes(value: str, generator: Optional[Generator] = None) -> Matcher: + """ + Returns a matcher that matches a string that includes the given value. + + Args: + value (str): + The value to match against. + generator (Optional[Generator], optional): + The generator to use when generating the value. Defaults to None. + """ + return ConcreteMatcher("include", value, generator=generator, force_generator=True) + + +def array_containing(variants: List[MatchType]) -> Matcher: + """ + Returns a matcher that matches the items in an array against a number of variants. + + Matching is successful if each variant occurs once in the array. Variants may be + objects containing matching rules. + + Args: + variants (List[MatchType]): + A list of variants to match against. + """ + return ConcreteMatcher("arrayContains", variants=variants) + + +def regex(regex: str, value: Optional[str] = None) -> Matcher: + """ + Returns a matcher that matches a string against a regular expression. + + If no value is provided, a random string will be generated that matches + the regular expression. + + Args: + regex (str): + The regular expression to match against. + value (str, optional): + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "regex", + value, + generator=regex_generator(regex), + regex=regex, + ) + + +def each_key_matches( + value: MatchType, rules: Union[Matcher | List[Matcher]] +) -> Matcher: + """ + Returns a matcher that matches each key in a dictionary against a set of rules. + + Args: + value (MatchType): + The value to match against. + rules (Union[Matcher, List[Matcher]]): + The matching rules to match against each key. + """ + if isinstance(rules, Matcher): + rules = [rules] + return ConcreteMatcher("eachKey", value, rules=rules) + + +def each_value_matches( + value: MatchType, rules: Union[Matcher | List[Matcher]] +) -> Matcher: + """ + Returns a matcher that matches each value in a dictionary against a set of rules. + + Args: + value (MatchType): + The value to match against. + rules (Union[Matcher, List[Matcher]]): + The matching rules to match against each value. + """ + if isinstance(rules, Matcher): + rules = [rules] + return ConcreteMatcher("eachValue", value, rules=rules) __all__ = [ + "array_containing", + "boolean", + "date", "decimal", + "each_key_matches", "each_like", + "each_value_matches", "integer", - "include", + "includes", "like", - "type", + "number", + "null", "regex", + "string", + "time", + "timestamp", + "type", "Matcher", "MatcherEncoder", ] diff --git a/src/pact/v3/matchers/matchers.py b/src/pact/v3/matchers/matchers.py deleted file mode 100644 index 68210ab62..000000000 --- a/src/pact/v3/matchers/matchers.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -from json import JSONEncoder -from typing import Any, Dict, Optional, Union - - -class Matcher: - def __init__( - self, - matcher_type: str, - value: Optional[Any] = None, # noqa: ANN401 - generator: Optional[str] = None, - **kwargs: Optional[str | int | float | bool], - ) -> None: - self.type = matcher_type - self.value = value - self.generator = generator - self.extra_attrs = {k: v for k, v in kwargs.items() if v is not None} - - def to_json(self) -> Union[str, Dict[str, Any]]: - json_data: Dict[str, Any] = { - "pact:matcher:type": self.type, - } - if self.value is not None: - json_data["value"] = self.value - if self.generator is not None: - json_data["pact:generator:type"] = self.generator - [json_data.update({k: v}) for k, v in self.extra_attrs.items() if v is not None] - return json_data - - -class MatcherEncoder(JSONEncoder): - def default(self, obj: Any) -> Union[str, Dict[str, Any]]: # noqa: ANN401 - if isinstance(obj, Matcher): - return obj.to_json() - return super().default(obj) - - -def decimal(value: float) -> Matcher: - return Matcher("decimal", value) - - -def each_like( - value: Any, # noqa: ANN401 - min_count: Optional[int] = None, - max_count: Optional[int] = None, -) -> Matcher: - return Matcher("type", [value], min=min_count, max=max_count) - - -def include(value: str, include: str) -> Matcher: - return Matcher("include", value, include=include) - - -def integer(value: int) -> Matcher: - return Matcher("integer", value) - - -def like( - value: Any, # noqa: ANN401 - min_count: Optional[int] = None, - max_count: Optional[int] = None, -) -> Matcher: - return Matcher("type", value, min=min_count, max=max_count) - - -def regex(value: str, regex: str, generator: Optional[str] = None) -> Matcher: - return Matcher("regex", value, regex=regex, generator=generator) From a8435381bd2bc9f8d73a5b2cc190a9ae92e6fa33 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Fri, 13 Sep 2024 14:09:43 -0600 Subject: [PATCH 03/18] linting and python version fixes --- examples/tests/v3/basic_flask_server.py | 6 + examples/tests/v3/test_matchers.py | 6 + src/pact/v3/generators/__init__.py | 242 +--------- src/pact/v3/generators/generators.py | 232 ++++++++++ src/pact/v3/interaction/_base.py | 6 +- src/pact/v3/interaction/_http_interaction.py | 8 +- src/pact/v3/matchers/__init__.py | 452 +------------------ src/pact/v3/matchers/matchers.py | 437 ++++++++++++++++++ tests/v3/test_http_interaction.py | 34 +- 9 files changed, 758 insertions(+), 665 deletions(-) create mode 100644 src/pact/v3/generators/generators.py create mode 100644 src/pact/v3/matchers/matchers.py diff --git a/examples/tests/v3/basic_flask_server.py b/examples/tests/v3/basic_flask_server.py index 9d7ec9bba..d844ea34a 100644 --- a/examples/tests/v3/basic_flask_server.py +++ b/examples/tests/v3/basic_flask_server.py @@ -114,6 +114,12 @@ def hello_world(test_id: int) -> Response: for _ in range(randint(3, 5)) # noqa: S311 ], "arrayContainingMatches": [randint(1, 100), randint(1, 100)], # noqa: S311 + "numbers": { + "intMatches": 42, + "floatMatches": 3.1415, + "intGeneratorMatches": randint(1, 100), # noqa: S311, + "decimalGeneratorMatches": round(uniform(10, 99), 2), # noqa: S311 + }, "dateMatches": "1999-12-31", "randomDateMatches": "1999-12-31", "timeMatches": "12:34:56", diff --git a/examples/tests/v3/test_matchers.py b/examples/tests/v3/test_matchers.py index e5f37f46d..3741fc8f6 100644 --- a/examples/tests/v3/test_matchers.py +++ b/examples/tests/v3/test_matchers.py @@ -57,6 +57,12 @@ def test_matchers() -> None: matchers.integer(1), matchers.integer(2), ]), + "numbers": { + "intMatches": matchers.number(42), + "floatMatches": matchers.number(3.1415), + "intGeneratorMatches": matchers.number(max_val=10), + "decimalGeneratorMatches": matchers.number(digits=4), + }, "dateMatches": matchers.date("yyyy-MM-dd", "2024-01-01"), "randomDateMatches": matchers.date("yyyy-MM-dd"), "timeMatches": matchers.time("HH:mm:ss", "12:34:56"), diff --git a/src/pact/v3/generators/__init__.py b/src/pact/v3/generators/__init__.py index 0fe2f789e..605407173 100644 --- a/src/pact/v3/generators/__init__.py +++ b/src/pact/v3/generators/__init__.py @@ -1,229 +1,25 @@ """ -Generator implementations for pact-python. +Generator module. """ -from __future__ import annotations - -from typing import Any, Literal, Optional, Union - -type GeneratorTypeV3 = Literal[ - "RandomInt", - "RandomDecimal", - "RandomHexadecimal", - "RandomString", - "Regex", - "Uuid", - "Date", - "Time", - "DateTime", - "RandomBoolean", -] - -type GeneratorTypeV4 = Union[GeneratorTypeV3, Literal["ProviderState", "MockServerURL"]] - -type GeneratorTypes = Union[GeneratorTypeV3, GeneratorTypeV4] - - -class Generator: - """ - Generator interface for exporting. - """ - - -class ConcreteGenerator(Generator): - """ - ConcreteGenerator class. - - A generator is used to generate values for a field in a response. - """ - - def __init__( - self, - generator_type: GeneratorTypeV4, - extra_args: Optional[dict[str, Any]] = None, - ) -> None: - """ - Instantiate the generator class. - - Args: - generator_type (GeneratorTypeV4): - The type of generator to use. - extra_args (dict[str, Any], optional): - Additional configuration elements to pass to the generator. - """ - self.type = generator_type - self.extra_args = extra_args if extra_args is not None else {} - - def to_dict(self) -> str: - """ - Convert the generator to a dictionary for json serialization. - """ - data = { - "pact:generator:type": self.type, - } - data.update({k: v for k, v in self.extra_args.items() if v is not None}) - return data - - -def random_int( - min_val: Optional[int] = None, max_val: Optional[int] = None -) -> Generator: - """ - Create a random integer generator. - - Args: - min_val (Optional[int], optional): - The minimum value for the integer. - max_val (Optional[int], optional): - The maximum value for the integer. - """ - return ConcreteGenerator("RandomInt", {"min": min_val, "max": max_val}) - - -def random_decimal(digits: Optional[int] = None) -> Generator: - """ - Create a random decimal generator. - - Args: - digits (Optional[int], optional): - The number of digits to generate. - """ - return ConcreteGenerator("RandomDecimal", {"digits": digits}) - - -def random_hexadecimal(digits: Optional[int] = None) -> Generator: - """ - Create a random hexadecimal generator. - - Args: - digits (Optional[int], optional): - The number of digits to generate. - """ - return ConcreteGenerator("RandomHexadecimal", {"digits": digits}) - - -def random_string(size: Optional[int] = None) -> Generator: - """ - Create a random string generator. - - Args: - size (Optional[int], optional): - The size of the string to generate. - """ - return ConcreteGenerator("RandomString", {"size": size}) - - -def regex(regex: str) -> Generator: - """ - Create a regex generator. - - This will generate a string that matches the given regex. - - Args: - regex (str): - The regex pattern to match. - """ - return ConcreteGenerator("Regex", {"regex": regex}) - - -def uuid( - format_str: Optional[ - Literal["simple", "lower-case-hyphenated", "upper-case-hyphenated", "URN"] - ] = None, -) -> Generator: - """ - Create a UUID generator. - - Args: - format_str (Optional[Literal[]], optional): - The format of the UUID to generate. This parameter is only supported - under the V4 specification. - """ - return ConcreteGenerator("Uuid", {"format": format_str}) - - -def date(format_str: str) -> Generator: - """ - Create a date generator. - - This will generate a date string that matches the given format. See - [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) - for more information on the format string. - - Args: - format_str (str): - The format string to use for the date. - """ - return ConcreteGenerator("Date", {"format": format_str}) - - -def time(format_str: str) -> Generator: - """ - Create a time generator. - - This will generate a time string that matches the given format. See - [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) - for more information on the format string. - - Args: - format_str (str): - The format string to use for the time. - """ - return ConcreteGenerator("Time", {"format": format_str}) - - -def date_time(format_str: str) -> Generator: - """ - Create a date-time generator. - - This will generate a date-time string that matches the given format. See - [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) - for more information on the format string. - - Args: - format_str (str): - The format string to use for the date-time. - """ - return ConcreteGenerator("DateTime", {"format": format_str}) - - -def random_boolean() -> Generator: - """ - Create a random boolean generator. - """ - return ConcreteGenerator("RandomBoolean") - - -def provider_state(expression: Optional[str] = None) -> Generator: - """ - Create a provider state generator. - - Generates a value that is looked up from the provider state context - using the given expression. - - Args: - expression (Optional[str], optional): - The expression to use to look up the provider state. - """ - return ConcreteGenerator("ProviderState", {"expression": expression}) - - -def mock_server_url( - regex: Optional[str] = None, example: Optional[str] = None -) -> Generator: - """ - Create a mock server URL generator. - - Generates a URL with the mock server as the base URL. - - Args: - regex (Optional[str], optional): - The regex pattern to match. - example (Optional[str], optional): - An example URL to use. - """ - return ConcreteGenerator("MockServerURL", {"regex": regex, "example": example}) - +from pact.v3.generators.generators import ( + Generator, + GeneratorTypes, + GeneratorTypeV3, + GeneratorTypeV4, + date, + date_time, + mock_server_url, + provider_state, + random_boolean, + random_decimal, + random_hexadecimal, + random_int, + random_string, + regex, + time, + uuid, +) __all__ = [ "Generator", diff --git a/src/pact/v3/generators/generators.py b/src/pact/v3/generators/generators.py new file mode 100644 index 000000000..f83f58763 --- /dev/null +++ b/src/pact/v3/generators/generators.py @@ -0,0 +1,232 @@ +""" +Implementations of generators for the V3 and V4 specifications. +""" + +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Any, Literal, Optional, Union + +GeneratorTypeV3 = Literal[ + "RandomInt", + "RandomDecimal", + "RandomHexadecimal", + "RandomString", + "Regex", + "Uuid", + "Date", + "Time", + "DateTime", + "RandomBoolean", +] + +GeneratorTypeV4 = Union[GeneratorTypeV3, Literal["ProviderState", "MockServerURL"]] + +GeneratorTypes = Union[GeneratorTypeV3, GeneratorTypeV4] + + +class Generator(metaclass=ABCMeta): + """ + Generator interface for exporting. + """ + + @abstractmethod + def to_dict(self) -> dict[str, Any]: + """ + Convert the generator to a dictionary for json serialization. + """ + + +class ConcreteGenerator(Generator): + """ + ConcreteGenerator class. + + A generator is used to generate values for a field in a response. + """ + + def __init__( + self, + generator_type: GeneratorTypeV4, + extra_args: Optional[dict[str, Any]] = None, + ) -> None: + """ + Instantiate the generator class. + + Args: + generator_type (GeneratorTypeV4): + The type of generator to use. + extra_args (dict[str, Any], optional): + Additional configuration elements to pass to the generator. + """ + self.type = generator_type + self.extra_args = extra_args if extra_args is not None else {} + + def to_dict(self) -> dict[str, Any]: + """ + Convert the generator to a dictionary for json serialization. + """ + data = { + "pact:generator:type": self.type, + } + data.update({k: v for k, v in self.extra_args.items() if v is not None}) + return data + + +def random_int( + min_val: Optional[int] = None, max_val: Optional[int] = None +) -> Generator: + """ + Create a random integer generator. + + Args: + min_val (Optional[int], optional): + The minimum value for the integer. + max_val (Optional[int], optional): + The maximum value for the integer. + """ + return ConcreteGenerator("RandomInt", {"min": min_val, "max": max_val}) + + +def random_decimal(digits: Optional[int] = None) -> Generator: + """ + Create a random decimal generator. + + Args: + digits (Optional[int], optional): + The number of digits to generate. + """ + return ConcreteGenerator("RandomDecimal", {"digits": digits}) + + +def random_hexadecimal(digits: Optional[int] = None) -> Generator: + """ + Create a random hexadecimal generator. + + Args: + digits (Optional[int], optional): + The number of digits to generate. + """ + return ConcreteGenerator("RandomHexadecimal", {"digits": digits}) + + +def random_string(size: Optional[int] = None) -> Generator: + """ + Create a random string generator. + + Args: + size (Optional[int], optional): + The size of the string to generate. + """ + return ConcreteGenerator("RandomString", {"size": size}) + + +def regex(regex: str) -> Generator: + """ + Create a regex generator. + + This will generate a string that matches the given regex. + + Args: + regex (str): + The regex pattern to match. + """ + return ConcreteGenerator("Regex", {"regex": regex}) + + +def uuid( + format_str: Optional[ + Literal["simple", "lower-case-hyphenated", "upper-case-hyphenated", "URN"] + ] = None, +) -> Generator: + """ + Create a UUID generator. + + Args: + format_str (Optional[Literal[]], optional): + The format of the UUID to generate. This parameter is only supported + under the V4 specification. + """ + return ConcreteGenerator("Uuid", {"format": format_str}) + + +def date(format_str: str) -> Generator: + """ + Create a date generator. + + This will generate a date string that matches the given format. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for more information on the format string. + + Args: + format_str (str): + The format string to use for the date. + """ + return ConcreteGenerator("Date", {"format": format_str}) + + +def time(format_str: str) -> Generator: + """ + Create a time generator. + + This will generate a time string that matches the given format. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for more information on the format string. + + Args: + format_str (str): + The format string to use for the time. + """ + return ConcreteGenerator("Time", {"format": format_str}) + + +def date_time(format_str: str) -> Generator: + """ + Create a date-time generator. + + This will generate a date-time string that matches the given format. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for more information on the format string. + + Args: + format_str (str): + The format string to use for the date-time. + """ + return ConcreteGenerator("DateTime", {"format": format_str}) + + +def random_boolean() -> Generator: + """ + Create a random boolean generator. + """ + return ConcreteGenerator("RandomBoolean") + + +def provider_state(expression: Optional[str] = None) -> Generator: + """ + Create a provider state generator. + + Generates a value that is looked up from the provider state context + using the given expression. + + Args: + expression (Optional[str], optional): + The expression to use to look up the provider state. + """ + return ConcreteGenerator("ProviderState", {"expression": expression}) + + +def mock_server_url( + regex: Optional[str] = None, example: Optional[str] = None +) -> Generator: + """ + Create a mock server URL generator. + + Generates a URL with the mock server as the base URL. + + Args: + regex (Optional[str], optional): + The regex pattern to match. + example (Optional[str], optional): + An example URL to use. + """ + return ConcreteGenerator("MockServerURL", {"regex": regex, "example": example}) diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index 410ea2e9a..42c0bef1c 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -267,10 +267,10 @@ def with_body( If `None`, then the function intelligently determines whether the body should be added to the request or the response. """ - if body and not isinstance(body, str): - body_str = json.dumps(body, cls=MatcherEncoder) - else: + if body and isinstance(body, str): body_str = body + else: + body_str = json.dumps(body, cls=MatcherEncoder) pact.v3.ffi.with_body( self._handle, diff --git a/src/pact/v3/interaction/_http_interaction.py b/src/pact/v3/interaction/_http_interaction.py index 14cd4833a..61346ba49 100644 --- a/src/pact/v3/interaction/_http_interaction.py +++ b/src/pact/v3/interaction/_http_interaction.py @@ -6,7 +6,7 @@ import json from collections import defaultdict -from typing import TYPE_CHECKING, Iterable, Literal +from typing import TYPE_CHECKING, Any, Iterable, Literal import pact.v3.ffi from pact.v3.interaction._base import Interaction @@ -426,7 +426,7 @@ def with_query_parameter(self, name: str, value: str | dict | Matcher) -> Self: def with_query_parameters( self, - parameters: dict[str, str] | Iterable[tuple[str, str]] | Matcher, + parameters: dict[str, Any] | Iterable[tuple[str, Any]], ) -> Self: """ Add multiple query parameters to the request. @@ -439,10 +439,6 @@ def with_query_parameters( parameters: Query parameters to add to the request. """ - if isinstance(parameters, Matcher): - matcher_dict = json.dumps(parameters, cls=MatcherEncoder) - for name, value in matcher_dict.items(): - self.with_query_parameter(name, value) if isinstance(parameters, dict): parameters = parameters.items() for name, value in parameters: diff --git a/src/pact/v3/matchers/__init__.py b/src/pact/v3/matchers/__init__.py index 29d4967f1..927c0a807 100644 --- a/src/pact/v3/matchers/__init__.py +++ b/src/pact/v3/matchers/__init__.py @@ -1,438 +1,27 @@ """ -Matcher implementations for pact-python. +Matchers module. """ -from __future__ import annotations -from json import JSONEncoder -from typing import Any, Dict, List, Literal, Optional, Union, overload - -from pact.v3.generators import ( - Generator, - date_time, - random_boolean, - random_decimal, - random_int, - random_string, -) -from pact.v3.generators import ( - date as date_generator, -) -from pact.v3.generators import ( - regex as regex_generator, +from pact.v3.matchers.matchers import ( + Matcher, + MatcherEncoder, + array_containing, + boolean, + date, + decimal, + each_key_matches, + each_like, + each_value_matches, + includes, + integer, + like, + null, + number, + regex, + string, + time, + timestamp, ) -from pact.v3.generators import ( - time as time_generator, -) - -type MatcherTypeV3 = Literal[ - "equality", - "regex", - "type", - "type", - "include", - "integer", - "decimal", - "number", - "timestamp", - "time", - "date", - "null", - "boolean", - "contentType", - "values", - "arrayContains", -] - -type MatcherTypeV4 = Union[ - MatcherTypeV3, - Literal[ - "statusCode", - "notEmpty", - "semver", - "eachKey", - "eachValue", - ], -] - -class Matcher: - """ - Matcher interface for exporting. - """ - -class ConcreteMatcher(Matcher): - """ - ConcreteMatcher class. - """ - - def __init__( - self, - matcher_type: MatcherTypeV4, - value: Optional[Any] = None, # noqa: ANN401 - generator: Optional[Generator] = None, - *, - force_generator: Optional[boolean] = False, - **kwargs: Optional[str | int | float | bool], - ) -> None: - """ - Initialize the matcher class. - - Args: - matcher_type (MatcherTypeV4): - The type of the matcher. - value (Any, optional): - The value to return when running a consumer test. - Defaults to None. - generator (Optional[Generator], optional): - The generator to use when generating the value. The generator will - generally only be used if value is not provided. Defaults to None. - force_generator (Optional[boolean], optional): - If True, the generator will be used to generate a value even if - a value is provided. Defaults to False. - **kwargs (Optional[str | int | float | bool], optional): - Additional configuration elements to pass to the matcher. - """ - self.type = matcher_type - self.value = value - self.generator = generator - self.force_generator = force_generator - self.extra_attrs = {k: v for k, v in kwargs.items() if v is not None} - - def to_dict(self) -> Dict[str, Any]: - """ - Convert the matcher to a dictionary for json serialization. - """ - data: Dict[str, Any] = { - "pact:matcher:type": self.type, - } - data["value"] = self.value if self.value is not None else "" - if self.generator is not None and (self.value is None or self.force_generator): - data.update(self.generator.to_dict()) - [data.update({k: v}) for k, v in self.extra_attrs.items() if v is not None] - return data - - -class MatcherEncoder(JSONEncoder): - """ - Matcher encoder class for json serialization. - """ - - def default(self, obj: Any) -> Union[str, Dict[str, Any], List[Any]]: # noqa: ANN401 - """ - Encode the object to json. - """ - if isinstance(obj, Matcher): - return obj.to_dict() - return super().default(obj) - - -type MatchType = str | int | float | bool | dict | list | tuple | None | Matcher - - -def integer( - value: Optional[int] = None, - min_val: Optional[int] = None, - max_val: Optional[int] = None, -) -> Matcher: - """ - Returns a matcher that matches an integer value. - - Args: - value (int, optional): - The value to return when running a consumer test. Defaults to None. - min_val (int, optional): - The minimum value of the integer to generate. Defaults to None. - max_val (int, optional): - The maximum value of the integer to generate. Defaults to None. - """ - return ConcreteMatcher( - "integer", - value, - generator=random_int(min_val, max_val), - ) - - -def decimal(value: Optional[float] = None, digits: Optional[int] = None) -> Matcher: - """ - Returns a matcher that matches a decimal value. - - Args: - value (float, optional): - The value to return when running a consumer test. Defaults to None. - digits (int, optional): - The number of decimal digits to generate. Defaults to None. - """ - return ConcreteMatcher("decimal", value, generator=random_decimal(digits)) - - -@overload -def number( - value: Optional[int] = None, - min_val: Optional[int] = None, - max_val: Optional[int] = None, -) -> Matcher: ... - - -@overload -def number(value: Optional[float] = None, digits: Optional[int] = None) -> Matcher: ... - - -def number( - value: Optional[Union[int, float]] = None, - min_val: Optional[Union[int, float]] = None, - max_val: Optional[Union[int, float]] = None, - digits: Optional[int] = None, -) -> Matcher: - """ - Returns a matcher that matches a number value. - - Args: - value (int, float, optional): - The value to return when running a consumer test. - Defaults to None. - min_val (int, float, optional): - The minimum value of the number to generate. Only used when - value is an integer. Defaults to None. - max_val (int, float, optional): - The maximum value of the number to generate. Only used when - value is an integer. Defaults to None. - digits (int, optional): - The number of decimal digits to generate. Only used when - value is a float. Defaults to None. - """ - if isinstance(value, int): - generator = random_int(min_val, max_val) - else: - generator = random_decimal(digits) - return ConcreteMatcher("number", value, generator=generator) - - -def string( - value: Optional[str] = None, - size: Optional[int] = None, - generator: Optional[Generator] = None, -) -> Matcher: - """ - Returns a matcher that matches a string value. - - Args: - value (str, optional): - The value to return when running a consumer test. Defaults to None. - size (int, optional): - The size of the string to generate. Defaults to None. - generator (Optional[Generator], optional): - The generator to use when generating the value. Defaults to None. If - no generator is provided and value is not provided, a random string - generator will be used. - """ - if generator is not None: - return ConcreteMatcher("type", value, generator=generator, force_generator=True) - return ConcreteMatcher("type", value, generator=random_string(size)) - - -def boolean(*, value: Optional[bool] = True) -> Matcher: - """ - Returns a matcher that matches a boolean value. - - Args: - value (Optional[bool], optional): - The value to return when running a consumer test. Defaults to True. - """ - return ConcreteMatcher("boolean", value, generator=random_boolean()) - - -def date(format_str: str, value: Optional[str] = None) -> Matcher: - """ - Returns a matcher that matches a date value. - - Args: - format_str (str): - The format of the date. See - [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) - for details on the format string. - value (str, optional): - The value to return when running a consumer test. Defaults to None. - """ - return ConcreteMatcher( - "date", value, format=format_str, generator=date_generator(format_str) - ) - - -def time(format_str: str, value: Optional[str] = None) -> Matcher: - """ - Returns a matcher that matches a time value. - - Args: - format_str (str): - The format of the time. See - [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) - for details on the format string. - value (str, optional): - The value to return when running a consumer test. Defaults to None. - """ - return ConcreteMatcher( - "time", value, format=format_str, generator=time_generator(format_str) - ) - - -def timestamp(format_str: str, value: Optional[str] = None) -> Matcher: - """ - Returns a matcher that matches a timestamp value. - - Args: - format_str (str): - The format of the timestamp. See - [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) - for details on the format string. - value (str, optional): - The value to return when running a consumer test. Defaults to None. - """ - return ConcreteMatcher( - "timestamp", - value, - format=format_str, - generator=date_time(format_str), - ) - - -def null() -> Matcher: - """ - Returns a matcher that matches a null value. - """ - return ConcreteMatcher("null") - - -def like( - value: MatchType, - min_count: Optional[int] = None, - max_count: Optional[int] = None, - generator: Optional[Generator] = None, -) -> Matcher: - """ - Returns a matcher that matches the given template. - - Args: - value (MatchType): - The template to match against. This can be a primitive value, a - dictionary, or a list and matching will be done by type. - min_count (int, optional): - The minimum number of items that must match the value. Defaults to None. - max_count (int, optional): - The maximum number of items that must match the value. Defaults to None. - generator (Optional[Generator], optional): - The generator to use when generating the value. Defaults to None. - """ - return ConcreteMatcher( - "type", - value, - min=min_count, - max=max_count, - generator=generator - ) - - -def each_like( - value: MatchType, - min_count: Optional[int] = 1, - max_count: Optional[int] = None, -) -> Matcher: - """ - Returns a matcher that matches each item in an array against a given value. - - Note that the matcher will validate the array length be at least one. - Also, the argument passed will be used as a template to match against - each item in the array and generally should not itself be an array. - - Args: - value (MatchType): - The value to match against. - min_count (int, optional): - The minimum number of items that must match the value. Default is 1. - max_count (int, optional): - The maximum number of items that must match the value. - """ - return ConcreteMatcher("type", [value], min=min_count, max=max_count) - - -def includes(value: str, generator: Optional[Generator] = None) -> Matcher: - """ - Returns a matcher that matches a string that includes the given value. - - Args: - value (str): - The value to match against. - generator (Optional[Generator], optional): - The generator to use when generating the value. Defaults to None. - """ - return ConcreteMatcher("include", value, generator=generator, force_generator=True) - - -def array_containing(variants: List[MatchType]) -> Matcher: - """ - Returns a matcher that matches the items in an array against a number of variants. - - Matching is successful if each variant occurs once in the array. Variants may be - objects containing matching rules. - - Args: - variants (List[MatchType]): - A list of variants to match against. - """ - return ConcreteMatcher("arrayContains", variants=variants) - - -def regex(regex: str, value: Optional[str] = None) -> Matcher: - """ - Returns a matcher that matches a string against a regular expression. - - If no value is provided, a random string will be generated that matches - the regular expression. - - Args: - regex (str): - The regular expression to match against. - value (str, optional): - The value to return when running a consumer test. Defaults to None. - """ - return ConcreteMatcher( - "regex", - value, - generator=regex_generator(regex), - regex=regex, - ) - - -def each_key_matches( - value: MatchType, rules: Union[Matcher | List[Matcher]] -) -> Matcher: - """ - Returns a matcher that matches each key in a dictionary against a set of rules. - - Args: - value (MatchType): - The value to match against. - rules (Union[Matcher, List[Matcher]]): - The matching rules to match against each key. - """ - if isinstance(rules, Matcher): - rules = [rules] - return ConcreteMatcher("eachKey", value, rules=rules) - - -def each_value_matches( - value: MatchType, rules: Union[Matcher | List[Matcher]] -) -> Matcher: - """ - Returns a matcher that matches each value in a dictionary against a set of rules. - - Args: - value (MatchType): - The value to match against. - rules (Union[Matcher, List[Matcher]]): - The matching rules to match against each value. - """ - if isinstance(rules, Matcher): - rules = [rules] - return ConcreteMatcher("eachValue", value, rules=rules) __all__ = [ "array_containing", @@ -451,7 +40,6 @@ def each_value_matches( "string", "time", "timestamp", - "type", "Matcher", "MatcherEncoder", ] diff --git a/src/pact/v3/matchers/matchers.py b/src/pact/v3/matchers/matchers.py new file mode 100644 index 000000000..baed3339d --- /dev/null +++ b/src/pact/v3/matchers/matchers.py @@ -0,0 +1,437 @@ +""" +Implementation of matchers for the V3 and V4 Pact specification. +""" + +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from json import JSONEncoder +from typing import Any, Dict, List, Literal, Optional, Union + +from pact.v3.generators import ( + Generator, + date_time, + random_boolean, + random_decimal, + random_int, + random_string, +) +from pact.v3.generators import ( + date as date_generator, +) +from pact.v3.generators import ( + regex as regex_generator, +) +from pact.v3.generators import ( + time as time_generator, +) + +MatcherTypeV3 = Literal[ + "equality", + "regex", + "type", + "type", + "include", + "integer", + "decimal", + "number", + "timestamp", + "time", + "date", + "null", + "boolean", + "contentType", + "values", + "arrayContains", +] + +MatcherTypeV4 = Union[ + MatcherTypeV3, + Literal[ + "statusCode", + "notEmpty", + "semver", + "eachKey", + "eachValue", + ], +] + + +class Matcher(metaclass=ABCMeta): + """ + Matcher interface for exporting. + """ + + @abstractmethod + def to_dict(self) -> Dict[str, Any]: + """ + Convert the matcher to a dictionary for json serialization. + """ + + +MatchType = Union[str, int, float, bool, dict, list, tuple, None, Matcher] + + +class ConcreteMatcher(Matcher): + """ + ConcreteMatcher class. + """ + + def __init__( + self, + matcher_type: MatcherTypeV4, + value: Optional[Any] = None, # noqa: ANN401 + generator: Optional[Generator] = None, + *, + force_generator: Optional[bool] = False, + **kwargs: Optional[Union[MatchType, List[MatchType]]], + ) -> None: + """ + Initialize the matcher class. + + Args: + matcher_type (MatcherTypeV4): + The type of the matcher. + value (Any, optional): + The value to return when running a consumer test. + Defaults to None. + generator (Optional[Generator], optional): + The generator to use when generating the value. The generator will + generally only be used if value is not provided. Defaults to None. + force_generator (Optional[boolean], optional): + If True, the generator will be used to generate a value even if + a value is provided. Defaults to False. + **kwargs (Optional[Union[MatchType, List[MatchType]]], optional): + Additional configuration elements to pass to the matcher. + """ + self.type = matcher_type + self.value = value + self.generator = generator + self.force_generator = force_generator + self.extra_attrs = {k: v for k, v in kwargs.items() if v is not None} + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the matcher to a dictionary for json serialization. + """ + data: Dict[str, Any] = { + "pact:matcher:type": self.type, + } + data["value"] = self.value if self.value is not None else "" + if self.generator is not None and (self.value is None or self.force_generator): + data.update(self.generator.to_dict()) + [data.update({k: v}) for k, v in self.extra_attrs.items() if v is not None] + return data + + +class MatcherEncoder(JSONEncoder): + """ + Matcher encoder class for json serialization. + """ + + def default(self, obj: Any) -> Union[str, Dict[str, Any], List[Any]]: # noqa: ANN401 + """ + Encode the object to json. + """ + if isinstance(obj, Matcher): + return obj.to_dict() + return super().default(obj) + + +def integer( + value: Optional[int] = None, + min_val: Optional[int] = None, + max_val: Optional[int] = None, +) -> Matcher: + """ + Returns a matcher that matches an integer value. + + Args: + value (int, optional): + The value to return when running a consumer test. Defaults to None. + min_val (int, optional): + The minimum value of the integer to generate. Defaults to None. + max_val (int, optional): + The maximum value of the integer to generate. Defaults to None. + """ + return ConcreteMatcher( + "integer", + value, + generator=random_int(min_val, max_val), + ) + + +def decimal(value: Optional[float] = None, digits: Optional[int] = None) -> Matcher: + """ + Returns a matcher that matches a decimal value. + + Args: + value (float, optional): + The value to return when running a consumer test. Defaults to None. + digits (int, optional): + The number of decimal digits to generate. Defaults to None. + """ + return ConcreteMatcher("decimal", value, generator=random_decimal(digits)) + + +def number( + value: Optional[Union[int, float]] = None, + min_val: Optional[int] = None, + max_val: Optional[int] = None, + digits: Optional[int] = None, +) -> Matcher: + """ + Returns a matcher that matches a number value. + + If all arguments are None, a random_decimal generator will be used. + If value argument is an integer or either min_val or max_val are provided, + a random_int generator will be used. + + Args: + value (int, float, optional): + The value to return when running a consumer test. + Defaults to None. + min_val (int, float, optional): + The minimum value of the number to generate. Only used when + value is an integer. Defaults to None. + max_val (int, float, optional): + The maximum value of the number to generate. Only used when + value is an integer. Defaults to None. + digits (int, optional): + The number of decimal digits to generate. Only used when + value is a float. Defaults to None. + """ + if min_val is not None and digits is not None: + msg = "min_val and digits cannot be used together" + raise ValueError(msg) + + if isinstance(value, int) or any(v is not None for v in [min_val, max_val]): + generator = random_int(min_val, max_val) + else: + generator = random_decimal(digits) + return ConcreteMatcher("number", value, generator=generator) + + +def string( + value: Optional[str] = None, + size: Optional[int] = None, + generator: Optional[Generator] = None, +) -> Matcher: + """ + Returns a matcher that matches a string value. + + Args: + value (str, optional): + The value to return when running a consumer test. Defaults to None. + size (int, optional): + The size of the string to generate. Defaults to None. + generator (Optional[Generator], optional): + The generator to use when generating the value. Defaults to None. If + no generator is provided and value is not provided, a random string + generator will be used. + """ + if generator is not None: + return ConcreteMatcher("type", value, generator=generator, force_generator=True) + return ConcreteMatcher("type", value, generator=random_string(size)) + + +def boolean(*, value: Optional[bool] = True) -> Matcher: + """ + Returns a matcher that matches a boolean value. + + Args: + value (Optional[bool], optional): + The value to return when running a consumer test. Defaults to True. + """ + return ConcreteMatcher("boolean", value, generator=random_boolean()) + + +def date(format_str: str, value: Optional[str] = None) -> Matcher: + """ + Returns a matcher that matches a date value. + + Args: + format_str (str): + The format of the date. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for details on the format string. + value (str, optional): + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "date", value, format=format_str, generator=date_generator(format_str) + ) + + +def time(format_str: str, value: Optional[str] = None) -> Matcher: + """ + Returns a matcher that matches a time value. + + Args: + format_str (str): + The format of the time. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for details on the format string. + value (str, optional): + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "time", value, format=format_str, generator=time_generator(format_str) + ) + + +def timestamp(format_str: str, value: Optional[str] = None) -> Matcher: + """ + Returns a matcher that matches a timestamp value. + + Args: + format_str (str): + The format of the timestamp. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for details on the format string. + value (str, optional): + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "timestamp", + value, + format=format_str, + generator=date_time(format_str), + ) + + +def null() -> Matcher: + """ + Returns a matcher that matches a null value. + """ + return ConcreteMatcher("null") + + +def like( + value: MatchType, + min_count: Optional[int] = None, + max_count: Optional[int] = None, + generator: Optional[Generator] = None, +) -> Matcher: + """ + Returns a matcher that matches the given template. + + Args: + value (MatchType): + The template to match against. This can be a primitive value, a + dictionary, or a list and matching will be done by type. + min_count (int, optional): + The minimum number of items that must match the value. Defaults to None. + max_count (int, optional): + The maximum number of items that must match the value. Defaults to None. + generator (Optional[Generator], optional): + The generator to use when generating the value. Defaults to None. + """ + return ConcreteMatcher( + "type", value, min=min_count, max=max_count, generator=generator + ) + + +def each_like( + value: MatchType, + min_count: Optional[int] = 1, + max_count: Optional[int] = None, +) -> Matcher: + """ + Returns a matcher that matches each item in an array against a given value. + + Note that the matcher will validate the array length be at least one. + Also, the argument passed will be used as a template to match against + each item in the array and generally should not itself be an array. + + Args: + value (MatchType): + The value to match against. + min_count (int, optional): + The minimum number of items that must match the value. Default is 1. + max_count (int, optional): + The maximum number of items that must match the value. + """ + return ConcreteMatcher("type", [value], min=min_count, max=max_count) + + +def includes(value: str, generator: Optional[Generator] = None) -> Matcher: + """ + Returns a matcher that matches a string that includes the given value. + + Args: + value (str): + The value to match against. + generator (Optional[Generator], optional): + The generator to use when generating the value. Defaults to None. + """ + return ConcreteMatcher("include", value, generator=generator, force_generator=True) + + +def array_containing(variants: List[MatchType]) -> Matcher: + """ + Returns a matcher that matches the items in an array against a number of variants. + + Matching is successful if each variant occurs once in the array. Variants may be + objects containing matching rules. + + Args: + variants (List[MatchType]): + A list of variants to match against. + """ + return ConcreteMatcher("arrayContains", variants=variants) + + +def regex(regex: str, value: Optional[str] = None) -> Matcher: + """ + Returns a matcher that matches a string against a regular expression. + + If no value is provided, a random string will be generated that matches + the regular expression. + + Args: + regex (str): + The regular expression to match against. + value (str, optional): + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "regex", + value, + generator=regex_generator(regex), + regex=regex, + ) + + +def each_key_matches( + value: MatchType, rules: Union[Matcher | List[Matcher]] +) -> Matcher: + """ + Returns a matcher that matches each key in a dictionary against a set of rules. + + Args: + value (MatchType): + The value to match against. + rules (Union[Matcher, List[Matcher]]): + The matching rules to match against each key. + """ + if isinstance(rules, Matcher): + rules = [rules] + return ConcreteMatcher("eachKey", value, rules=rules) + + +def each_value_matches( + value: MatchType, rules: Union[Matcher | List[Matcher]] +) -> Matcher: + """ + Returns a matcher that matches each value in a dictionary against a set of rules. + + Args: + value (MatchType): + The value to match against. + rules (Union[Matcher, List[Matcher]]): + The matching rules to match against each value. + """ + if isinstance(rules, Matcher): + rules = [rules] + return ConcreteMatcher("eachValue", value, rules=rules) diff --git a/tests/v3/test_http_interaction.py b/tests/v3/test_http_interaction.py index b235cfbb2..c3a674795 100644 --- a/tests/v3/test_http_interaction.py +++ b/tests/v3/test_http_interaction.py @@ -12,7 +12,7 @@ import aiohttp import pytest -from pact.v3 import Pact +from pact.v3 import Pact, matchers from pact.v3.pact import MismatchesError if TYPE_CHECKING: @@ -304,6 +304,23 @@ async def test_with_query_parameter_request( assert resp.status == 200 +@pytest.mark.asyncio +async def test_with_query_parameter_with_matcher( + pact: Pact, +) -> None: + ( + pact.upon_receiving("a basic request with a query parameter") + .with_request("GET", "/") + .with_query_parameter("test", matchers.string("true")) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + url = srv.url.with_query([("test", "true")]) + async with session.request("GET", url.path_qs) as resp: + assert resp.status == 200 + + @pytest.mark.asyncio async def test_with_query_parameter_dict(pact: Pact) -> None: ( @@ -319,6 +336,21 @@ async def test_with_query_parameter_dict(pact: Pact) -> None: assert resp.status == 200 +@pytest.mark.asyncio +async def test_with_query_parameter_tuple_list(pact: Pact) -> None: + ( + pact.upon_receiving("a basic request with a query parameter from a dict") + .with_request("GET", "/") + .with_query_parameters([("test", "true"), ("foo", "bar")]) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + url = srv.url.with_query({"test": "true", "foo": "bar"}) + async with session.request("GET", url.path_qs) as resp: + assert resp.status == 200 + + @pytest.mark.parametrize( "method", ["GET", "POST", "PUT"], From 1ca2832a37de80c1882f096b6e9f95e7bb533a05 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Fri, 13 Sep 2024 14:23:15 -0600 Subject: [PATCH 04/18] linting and formatting fixes --- examples/tests/v3/basic_flask_server.py | 7 ++++--- examples/tests/v3/test_matchers.py | 8 ++++---- src/pact/v3/matchers/matchers.py | 1 - 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/tests/v3/basic_flask_server.py b/examples/tests/v3/basic_flask_server.py index d844ea34a..18b3c0c95 100644 --- a/examples/tests/v3/basic_flask_server.py +++ b/examples/tests/v3/basic_flask_server.py @@ -16,9 +16,10 @@ from typing import Generator, NoReturn import requests -from flask import Flask, Response, make_response from yarl import URL +from flask import Flask, Response, make_response + logger = logging.getLogger(__name__) @@ -95,12 +96,12 @@ def redirect() -> NoReturn: @app.route("/path/to/") def hello_world(test_id: int) -> Response: + random_regex_matches = "1-8 digits: 12345678, 1-8 random letters abcdefgh" response = make_response({ "response": { "id": test_id, "regexMatches": "must end with 'hello world'", - "randomRegexMatches": - "1-8 digits: 12345678, 1-8 random letters abcdefgh", + "randomRegexMatches": random_regex_matches, "integerMatches": test_id, "decimalMatches": round(uniform(0, 9), 3), # noqa: S311 "booleanMatches": True, diff --git a/examples/tests/v3/test_matchers.py b/examples/tests/v3/test_matchers.py index 3741fc8f6..69f8e9c8e 100644 --- a/examples/tests/v3/test_matchers.py +++ b/examples/tests/v3/test_matchers.py @@ -96,7 +96,7 @@ def test_matchers() -> None: assert ( response_data["response"]["regexMatches"] == "must end with 'hello world'" ) - assert response_data["response"]["integerMatches"] == 42 # noqa: PLR2004 + assert response_data["response"]["integerMatches"] == 42 assert response_data["response"]["booleanMatches"] is False assert response_data["response"]["includeMatches"] == "world" assert response_data["response"]["dateMatches"] == "2024-01-01" @@ -116,12 +116,12 @@ def test_matchers() -> None: ) random_integer = int(response_data["response"]["randomIntegerMatches"]) assert random_integer >= 1 - assert random_integer <= 100 # noqa: PLR2004 + assert random_integer <= 100 float(response_data["response"]["randomDecimalMatches"]) assert ( - len(response_data["response"]["randomDecimalMatches"].replace(".", "")) == 4 # noqa: PLR2004 + len(response_data["response"]["randomDecimalMatches"].replace(".", "")) == 4 ) - assert len(response_data["response"]["randomStringMatches"]) == 10 # noqa: PLR2004 + assert len(response_data["response"]["randomStringMatches"]) == 10 pact.write_file(pact_dir, overwrite=True) with start_provider() as url: diff --git a/src/pact/v3/matchers/matchers.py b/src/pact/v3/matchers/matchers.py index baed3339d..b45114d1c 100644 --- a/src/pact/v3/matchers/matchers.py +++ b/src/pact/v3/matchers/matchers.py @@ -30,7 +30,6 @@ "equality", "regex", "type", - "type", "include", "integer", "decimal", From 5262e130934b3a81f4ad007d7c463d8f06505e6c Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 19 Sep 2024 16:35:15 +1000 Subject: [PATCH 05/18] fix: missing typing arguments The `dict`, `list` and `tuple` types require argument. Also, the use of the lowercase variants is not compatible with Python 3.8 (which will be dropped soon anyway... but still need to support it for a little while longer). Signed-off-by: JP-Ellis --- src/pact/v3/matchers/matchers.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pact/v3/matchers/matchers.py b/src/pact/v3/matchers/matchers.py index b45114d1c..e44d6665d 100644 --- a/src/pact/v3/matchers/matchers.py +++ b/src/pact/v3/matchers/matchers.py @@ -6,7 +6,7 @@ from abc import ABCMeta, abstractmethod from json import JSONEncoder -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Mapping, Optional, Sequence, Tuple, Union from pact.v3.generators import ( Generator, @@ -68,7 +68,15 @@ def to_dict(self) -> Dict[str, Any]: """ -MatchType = Union[str, int, float, bool, dict, list, tuple, None, Matcher] +AtomicType = str | int | float | bool | None +MatchType = ( + AtomicType + | Dict[AtomicType, AtomicType] + | List[AtomicType] + | Tuple[AtomicType] + | Sequence[AtomicType] + | Mapping[AtomicType, AtomicType] +) class ConcreteMatcher(Matcher): From 834c73b3aacb064439a29ca86a542213b44250b5 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 19 Sep 2024 16:38:09 +1000 Subject: [PATCH 06/18] fix: incompatible override The `JSONEncoder` class uses `o` as the argument and does not enforce a positional argument. This means we need to support the possible use of `default(o=...)`. Signed-off-by: JP-Ellis --- src/pact/v3/matchers/matchers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pact/v3/matchers/matchers.py b/src/pact/v3/matchers/matchers.py index e44d6665d..6d6e7fbc5 100644 --- a/src/pact/v3/matchers/matchers.py +++ b/src/pact/v3/matchers/matchers.py @@ -136,13 +136,13 @@ class MatcherEncoder(JSONEncoder): Matcher encoder class for json serialization. """ - def default(self, obj: Any) -> Union[str, Dict[str, Any], List[Any]]: # noqa: ANN401 + def default(self, o: Any) -> Union[str, Dict[str, Any], List[Any]]: # noqa: ANN401 """ Encode the object to json. """ - if isinstance(obj, Matcher): - return obj.to_dict() - return super().default(obj) + if isinstance(o, Matcher): + return o.to_dict() + return super().default(o) def integer( From 2902425f54475347239a7711c143590a86ee7c7e Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 19 Sep 2024 16:41:47 +1000 Subject: [PATCH 07/18] fix: kwargs typing The type annotation that goes alongside a `**kwargs` types the _values_. Therefore a `**kwargs: Foo` will result in `kwargs` being of type `dict[str, Foo]`. Signed-off-by: JP-Ellis --- src/pact/v3/matchers/matchers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pact/v3/matchers/matchers.py b/src/pact/v3/matchers/matchers.py index 6d6e7fbc5..a7978ff13 100644 --- a/src/pact/v3/matchers/matchers.py +++ b/src/pact/v3/matchers/matchers.py @@ -91,7 +91,7 @@ def __init__( generator: Optional[Generator] = None, *, force_generator: Optional[bool] = False, - **kwargs: Optional[Union[MatchType, List[MatchType]]], + **kwargs: AtomicType, ) -> None: """ Initialize the matcher class. From bedd5a8c7d6381074e1724089cf0410b18d40ee2 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 19 Sep 2024 16:48:25 +1000 Subject: [PATCH 08/18] refactor: prefer `|` over Optional and Union While equivalent, Optional and Union quickly can become quite verbose and more difficult to parse. Compare ``` Optional[Union[str, bool]] ``` to ``` str | bool | None ``` Signed-off-by: JP-Ellis --- src/pact/v3/matchers/matchers.py | 154 +++++++++++++++---------------- 1 file changed, 75 insertions(+), 79 deletions(-) diff --git a/src/pact/v3/matchers/matchers.py b/src/pact/v3/matchers/matchers.py index a7978ff13..c6274819b 100644 --- a/src/pact/v3/matchers/matchers.py +++ b/src/pact/v3/matchers/matchers.py @@ -6,7 +6,7 @@ from abc import ABCMeta, abstractmethod from json import JSONEncoder -from typing import Any, Dict, List, Literal, Mapping, Optional, Sequence, Tuple, Union +from typing import Any, Dict, List, Literal, Mapping, Sequence, Tuple from pact.v3.generators import ( Generator, @@ -44,16 +44,16 @@ "arrayContains", ] -MatcherTypeV4 = Union[ - MatcherTypeV3, - Literal[ +MatcherTypeV4 = ( + MatcherTypeV3 + | Literal[ "statusCode", "notEmpty", "semver", "eachKey", "eachValue", - ], -] + ] +) class Matcher(metaclass=ABCMeta): @@ -87,28 +87,28 @@ class ConcreteMatcher(Matcher): def __init__( self, matcher_type: MatcherTypeV4, - value: Optional[Any] = None, # noqa: ANN401 - generator: Optional[Generator] = None, + value: Any | None = None, # noqa: ANN401 + generator: Generator | None = None, *, - force_generator: Optional[bool] = False, + force_generator: bool | None = False, **kwargs: AtomicType, ) -> None: """ Initialize the matcher class. Args: - matcher_type (MatcherTypeV4): + matcher_type: The type of the matcher. - value (Any, optional): + value: The value to return when running a consumer test. Defaults to None. - generator (Optional[Generator], optional): + generator: The generator to use when generating the value. The generator will generally only be used if value is not provided. Defaults to None. - force_generator (Optional[boolean], optional): + force_generator: If True, the generator will be used to generate a value even if a value is provided. Defaults to False. - **kwargs (Optional[Union[MatchType, List[MatchType]]], optional): + **kwargs: Additional configuration elements to pass to the matcher. """ self.type = matcher_type @@ -127,7 +127,7 @@ def to_dict(self) -> Dict[str, Any]: data["value"] = self.value if self.value is not None else "" if self.generator is not None and (self.value is None or self.force_generator): data.update(self.generator.to_dict()) - [data.update({k: v}) for k, v in self.extra_attrs.items() if v is not None] + data.update(self.extra_attrs) return data @@ -136,7 +136,7 @@ class MatcherEncoder(JSONEncoder): Matcher encoder class for json serialization. """ - def default(self, o: Any) -> Union[str, Dict[str, Any], List[Any]]: # noqa: ANN401 + def default(self, o: Any) -> Any: # noqa: ANN401 """ Encode the object to json. """ @@ -146,19 +146,19 @@ def default(self, o: Any) -> Union[str, Dict[str, Any], List[Any]]: # noqa: ANN def integer( - value: Optional[int] = None, - min_val: Optional[int] = None, - max_val: Optional[int] = None, + value: int | None = None, + min_val: int | None = None, + max_val: int | None = None, ) -> Matcher: """ Returns a matcher that matches an integer value. Args: - value (int, optional): + value: The value to return when running a consumer test. Defaults to None. - min_val (int, optional): + min_val: The minimum value of the integer to generate. Defaults to None. - max_val (int, optional): + max_val: The maximum value of the integer to generate. Defaults to None. """ return ConcreteMatcher( @@ -168,24 +168,24 @@ def integer( ) -def decimal(value: Optional[float] = None, digits: Optional[int] = None) -> Matcher: +def decimal(value: float | None = None, digits: int | None = None) -> Matcher: """ Returns a matcher that matches a decimal value. Args: - value (float, optional): + value: The value to return when running a consumer test. Defaults to None. - digits (int, optional): + digits: The number of decimal digits to generate. Defaults to None. """ return ConcreteMatcher("decimal", value, generator=random_decimal(digits)) def number( - value: Optional[Union[int, float]] = None, - min_val: Optional[int] = None, - max_val: Optional[int] = None, - digits: Optional[int] = None, + value: float | None = None, + min_val: float | None = None, + max_val: float | None = None, + digits: int | None = None, ) -> Matcher: """ Returns a matcher that matches a number value. @@ -195,16 +195,16 @@ def number( a random_int generator will be used. Args: - value (int, float, optional): + value: The value to return when running a consumer test. Defaults to None. - min_val (int, float, optional): + min_val: The minimum value of the number to generate. Only used when value is an integer. Defaults to None. - max_val (int, float, optional): + max_val: The maximum value of the number to generate. Only used when value is an integer. Defaults to None. - digits (int, optional): + digits: The number of decimal digits to generate. Only used when value is a float. Defaults to None. """ @@ -220,19 +220,19 @@ def number( def string( - value: Optional[str] = None, - size: Optional[int] = None, - generator: Optional[Generator] = None, + value: str | None = None, + size: int | None = None, + generator: Generator | None = None, ) -> Matcher: """ Returns a matcher that matches a string value. Args: - value (str, optional): + value: The value to return when running a consumer test. Defaults to None. - size (int, optional): + size: The size of the string to generate. Defaults to None. - generator (Optional[Generator], optional): + generator: The generator to use when generating the value. Defaults to None. If no generator is provided and value is not provided, a random string generator will be used. @@ -242,27 +242,27 @@ def string( return ConcreteMatcher("type", value, generator=random_string(size)) -def boolean(*, value: Optional[bool] = True) -> Matcher: +def boolean(*, value: bool | None = True) -> Matcher: """ Returns a matcher that matches a boolean value. Args: - value (Optional[bool], optional): + value: The value to return when running a consumer test. Defaults to True. """ return ConcreteMatcher("boolean", value, generator=random_boolean()) -def date(format_str: str, value: Optional[str] = None) -> Matcher: +def date(format_str: str, value: str | None = None) -> Matcher: """ Returns a matcher that matches a date value. Args: - format_str (str): + format_str: The format of the date. See [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) for details on the format string. - value (str, optional): + value: The value to return when running a consumer test. Defaults to None. """ return ConcreteMatcher( @@ -270,16 +270,16 @@ def date(format_str: str, value: Optional[str] = None) -> Matcher: ) -def time(format_str: str, value: Optional[str] = None) -> Matcher: +def time(format_str: str, value: str | None = None) -> Matcher: """ Returns a matcher that matches a time value. Args: - format_str (str): + format_str: The format of the time. See [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) for details on the format string. - value (str, optional): + value: The value to return when running a consumer test. Defaults to None. """ return ConcreteMatcher( @@ -287,16 +287,16 @@ def time(format_str: str, value: Optional[str] = None) -> Matcher: ) -def timestamp(format_str: str, value: Optional[str] = None) -> Matcher: +def timestamp(format_str: str, value: str | None = None) -> Matcher: """ Returns a matcher that matches a timestamp value. Args: - format_str (str): + format_str: The format of the timestamp. See [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) for details on the format string. - value (str, optional): + value: The value to return when running a consumer test. Defaults to None. """ return ConcreteMatcher( @@ -316,22 +316,22 @@ def null() -> Matcher: def like( value: MatchType, - min_count: Optional[int] = None, - max_count: Optional[int] = None, - generator: Optional[Generator] = None, + min_count: int | None = None, + max_count: int | None = None, + generator: Generator | None = None, ) -> Matcher: """ Returns a matcher that matches the given template. Args: - value (MatchType): + value: The template to match against. This can be a primitive value, a dictionary, or a list and matching will be done by type. - min_count (int, optional): + min_count: The minimum number of items that must match the value. Defaults to None. - max_count (int, optional): + max_count: The maximum number of items that must match the value. Defaults to None. - generator (Optional[Generator], optional): + generator: The generator to use when generating the value. Defaults to None. """ return ConcreteMatcher( @@ -341,8 +341,8 @@ def like( def each_like( value: MatchType, - min_count: Optional[int] = 1, - max_count: Optional[int] = None, + min_count: int | None = 1, + max_count: int | None = None, ) -> Matcher: """ Returns a matcher that matches each item in an array against a given value. @@ -352,24 +352,24 @@ def each_like( each item in the array and generally should not itself be an array. Args: - value (MatchType): + value: The value to match against. - min_count (int, optional): + min_count: The minimum number of items that must match the value. Default is 1. - max_count (int, optional): + max_count: The maximum number of items that must match the value. """ return ConcreteMatcher("type", [value], min=min_count, max=max_count) -def includes(value: str, generator: Optional[Generator] = None) -> Matcher: +def includes(value: str, generator: Generator | None = None) -> Matcher: """ Returns a matcher that matches a string that includes the given value. Args: - value (str): + value: The value to match against. - generator (Optional[Generator], optional): + generator: The generator to use when generating the value. Defaults to None. """ return ConcreteMatcher("include", value, generator=generator, force_generator=True) @@ -383,13 +383,13 @@ def array_containing(variants: List[MatchType]) -> Matcher: objects containing matching rules. Args: - variants (List[MatchType]): + variants: A list of variants to match against. """ return ConcreteMatcher("arrayContains", variants=variants) -def regex(regex: str, value: Optional[str] = None) -> Matcher: +def regex(regex: str, value: str | None = None) -> Matcher: """ Returns a matcher that matches a string against a regular expression. @@ -397,9 +397,9 @@ def regex(regex: str, value: Optional[str] = None) -> Matcher: the regular expression. Args: - regex (str): + regex: The regular expression to match against. - value (str, optional): + value: The value to return when running a consumer test. Defaults to None. """ return ConcreteMatcher( @@ -410,16 +410,14 @@ def regex(regex: str, value: Optional[str] = None) -> Matcher: ) -def each_key_matches( - value: MatchType, rules: Union[Matcher | List[Matcher]] -) -> Matcher: +def each_key_matches(value: MatchType, rules: Matcher | List[Matcher]) -> Matcher: """ Returns a matcher that matches each key in a dictionary against a set of rules. Args: - value (MatchType): + value: The value to match against. - rules (Union[Matcher, List[Matcher]]): + rules: The matching rules to match against each key. """ if isinstance(rules, Matcher): @@ -427,16 +425,14 @@ def each_key_matches( return ConcreteMatcher("eachKey", value, rules=rules) -def each_value_matches( - value: MatchType, rules: Union[Matcher | List[Matcher]] -) -> Matcher: +def each_value_matches(value: MatchType, rules: Matcher | List[Matcher]) -> Matcher: """ Returns a matcher that matches each value in a dictionary against a set of rules. Args: - value (MatchType): + value: The value to match against. - rules (Union[Matcher, List[Matcher]]): + rules: The matching rules to match against each value. """ if isinstance(rules, Matcher): From 783061169e488992729825a8aee19f3fbe1d7ded Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 20 Sep 2024 10:50:48 +1000 Subject: [PATCH 09/18] chore: prefer ABC over ABCMeta They are functionally equivalent, but with ABC being made to be more intuitive than the use of metaclass Signed-off-by: JP-Ellis --- src/pact/v3/matchers/matchers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pact/v3/matchers/matchers.py b/src/pact/v3/matchers/matchers.py index c6274819b..844b71544 100644 --- a/src/pact/v3/matchers/matchers.py +++ b/src/pact/v3/matchers/matchers.py @@ -4,7 +4,7 @@ from __future__ import annotations -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod from json import JSONEncoder from typing import Any, Dict, List, Literal, Mapping, Sequence, Tuple @@ -56,7 +56,7 @@ ) -class Matcher(metaclass=ABCMeta): +class Matcher(ABC): """ Matcher interface for exporting. """ From ff434a0aa51bc96fe7cbe144082c3decab9ed558 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 20 Sep 2024 11:00:19 +1000 Subject: [PATCH 10/18] docs: add matcher module preamble Signed-off-by: JP-Ellis --- src/pact/v3/matchers/matchers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pact/v3/matchers/matchers.py b/src/pact/v3/matchers/matchers.py index 844b71544..b2af3c503 100644 --- a/src/pact/v3/matchers/matchers.py +++ b/src/pact/v3/matchers/matchers.py @@ -1,5 +1,10 @@ """ -Implementation of matchers for the V3 and V4 Pact specification. +Matching functionality for Pact. + +Matchers are used in Pact to allow for more flexible matching of data. While the +consumer defines the expected request and response, there are circumstances +where the provider may return dynamically generated data. In these cases, the +consumer should use a matcher to define the expected data. """ from __future__ import annotations From 85d2feadd10b14484a98bbcb13850b9cbb991ac7 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 20 Sep 2024 11:00:59 +1000 Subject: [PATCH 11/18] refactor: rename matchers to match Signed-off-by: JP-Ellis --- examples/tests/v3/test_matchers.py | 70 ++++++++++---------- src/pact/v3/interaction/_base.py | 2 +- src/pact/v3/interaction/_http_interaction.py | 2 +- src/pact/v3/{matchers => match}/__init__.py | 2 +- src/pact/v3/{matchers => match}/matchers.py | 0 tests/v3/test_http_interaction.py | 4 +- 6 files changed, 40 insertions(+), 40 deletions(-) rename src/pact/v3/{matchers => match}/__init__.py (93%) rename src/pact/v3/{matchers => match}/matchers.py (100%) diff --git a/examples/tests/v3/test_matchers.py b/examples/tests/v3/test_matchers.py index 69f8e9c8e..615f3a36b 100644 --- a/examples/tests/v3/test_matchers.py +++ b/examples/tests/v3/test_matchers.py @@ -8,7 +8,7 @@ import requests from examples.tests.v3.basic_flask_server import start_provider -from pact.v3 import Pact, Verifier, generators, matchers +from pact.v3 import Pact, Verifier, generators, match def test_matchers() -> None: @@ -17,12 +17,12 @@ def test_matchers() -> None: ( pact.upon_receiving("a request") .given("a state", parameters={"providerStateArgument": "providerStateValue"}) - .with_request("GET", matchers.regex(r"/path/to/\d{1,4}", "/path/to/100")) + .with_request("GET", match.regex(r"/path/to/\d{1,4}", "/path/to/100")) .with_query_parameter( "asOf", - matchers.like( + match.like( [ - matchers.date("yyyy-MM-dd", "2024-01-01"), + match.date("yyyy-MM-dd", "2024-01-01"), ], min_count=1, max_count=1, @@ -30,62 +30,62 @@ def test_matchers() -> None: ) .will_respond_with(200) .with_body({ - "response": matchers.like( + "response": match.like( { - "regexMatches": matchers.regex( + "regexMatches": match.regex( r".*hello world'$", "must end with 'hello world'" ), - "randomRegexMatches": matchers.regex( + "randomRegexMatches": match.regex( r"1-8 digits: \d{1,8}, 1-8 random letters \w{1,8}" ), - "integerMatches": matchers.integer(42), - "decimalMatches": matchers.decimal(3.1415), - "randomIntegerMatches": matchers.integer(min_val=1, max_val=100), - "randomDecimalMatches": matchers.decimal(digits=4), - "booleanMatches": matchers.boolean(value=False), - "randomStringMatches": matchers.string(size=10), - "includeMatches": matchers.includes("world"), - "includeWithGeneratorMatches": matchers.includes( + "integerMatches": match.integer(42), + "decimalMatches": match.decimal(3.1415), + "randomIntegerMatches": match.integer(min_val=1, max_val=100), + "randomDecimalMatches": match.decimal(digits=4), + "booleanMatches": match.boolean(value=False), + "randomStringMatches": match.string(size=10), + "includeMatches": match.includes("world"), + "includeWithGeneratorMatches": match.includes( "world", generators.regex(r"\d{1,8} (hello )?world \d+") ), - "minMaxArrayMatches": matchers.each_like( - matchers.number(digits=2), + "minMaxArrayMatches": match.each_like( + match.number(digits=2), min_count=3, max_count=5, ), - "arrayContainingMatches": matchers.array_containing([ - matchers.integer(1), - matchers.integer(2), + "arrayContainingMatches": match.array_containing([ + match.integer(1), + match.integer(2), ]), "numbers": { - "intMatches": matchers.number(42), - "floatMatches": matchers.number(3.1415), - "intGeneratorMatches": matchers.number(max_val=10), - "decimalGeneratorMatches": matchers.number(digits=4), + "intMatches": match.number(42), + "floatMatches": match.number(3.1415), + "intGeneratorMatches": match.number(max_val=10), + "decimalGeneratorMatches": match.number(digits=4), }, - "dateMatches": matchers.date("yyyy-MM-dd", "2024-01-01"), - "randomDateMatches": matchers.date("yyyy-MM-dd"), - "timeMatches": matchers.time("HH:mm:ss", "12:34:56"), - "timestampMatches": matchers.timestamp( + "dateMatches": match.date("yyyy-MM-dd", "2024-01-01"), + "randomDateMatches": match.date("yyyy-MM-dd"), + "timeMatches": match.time("HH:mm:ss", "12:34:56"), + "timestampMatches": match.timestamp( "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "2024-01-01T12:34:56.000000" ), - "nullMatches": matchers.null(), - "eachKeyMatches": matchers.each_key_matches( + "nullMatches": match.null(), + "eachKeyMatches": match.each_key_matches( { - "id_1": matchers.each_value_matches( + "id_1": match.each_value_matches( { - "name": matchers.string(size=30), + "name": match.string(size=30), }, - rules=matchers.string("John Doe"), + rules=match.string("John Doe"), ) }, - rules=matchers.regex(r"id_\d+", "id_1"), + rules=match.regex(r"id_\d+", "id_1"), ), }, min_count=1, ) }) - .with_header("SpecialHeader", matchers.regex(r"Special: \w+", "Special: Foo")) + .with_header("SpecialHeader", match.regex(r"Special: \w+", "Special: Foo")) ) with pact.serve() as mockserver: response = requests.get( diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index 42c0bef1c..70ebfe641 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any, Literal, overload import pact.v3.ffi -from pact.v3.matchers import Matcher, MatcherEncoder +from pact.v3.match import Matcher, MatcherEncoder if TYPE_CHECKING: from pathlib import Path diff --git a/src/pact/v3/interaction/_http_interaction.py b/src/pact/v3/interaction/_http_interaction.py index 61346ba49..c1c682b6e 100644 --- a/src/pact/v3/interaction/_http_interaction.py +++ b/src/pact/v3/interaction/_http_interaction.py @@ -10,7 +10,7 @@ import pact.v3.ffi from pact.v3.interaction._base import Interaction -from pact.v3.matchers import Matcher, MatcherEncoder +from pact.v3.match import Matcher, MatcherEncoder if TYPE_CHECKING: try: diff --git a/src/pact/v3/matchers/__init__.py b/src/pact/v3/match/__init__.py similarity index 93% rename from src/pact/v3/matchers/__init__.py rename to src/pact/v3/match/__init__.py index 927c0a807..3131d1617 100644 --- a/src/pact/v3/matchers/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -2,7 +2,7 @@ Matchers module. """ -from pact.v3.matchers.matchers import ( +from pact.v3.match.matchers import ( Matcher, MatcherEncoder, array_containing, diff --git a/src/pact/v3/matchers/matchers.py b/src/pact/v3/match/matchers.py similarity index 100% rename from src/pact/v3/matchers/matchers.py rename to src/pact/v3/match/matchers.py diff --git a/tests/v3/test_http_interaction.py b/tests/v3/test_http_interaction.py index c3a674795..b700fddd7 100644 --- a/tests/v3/test_http_interaction.py +++ b/tests/v3/test_http_interaction.py @@ -12,7 +12,7 @@ import aiohttp import pytest -from pact.v3 import Pact, matchers +from pact.v3 import Pact, match from pact.v3.pact import MismatchesError if TYPE_CHECKING: @@ -311,7 +311,7 @@ async def test_with_query_parameter_with_matcher( ( pact.upon_receiving("a basic request with a query parameter") .with_request("GET", "/") - .with_query_parameter("test", matchers.string("true")) + .with_query_parameter("test", match.string("true")) .will_respond_with(200) ) with pact.serve() as srv: From 511c41a11d4855bf2d01665b1e8440f8fcce7904 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 20 Sep 2024 12:29:26 +1000 Subject: [PATCH 12/18] chore: re-organise match module In particular, splitting up the class, functions and types into separate modules. Signed-off-by: JP-Ellis --- src/pact/v3/match/__init__.py | 351 ++++++++++++++++++++++++++++++---- src/pact/v3/match/matchers.py | 334 +------------------------------- src/pact/v3/match/types.py | 15 ++ 3 files changed, 334 insertions(+), 366 deletions(-) create mode 100644 src/pact/v3/match/types.py diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index 3131d1617..b6796c2b0 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -2,44 +2,317 @@ Matchers module. """ -from pact.v3.match.matchers import ( - Matcher, - MatcherEncoder, - array_containing, - boolean, - date, - decimal, - each_key_matches, - each_like, - each_value_matches, - includes, - integer, - like, - null, - number, - regex, - string, - time, - timestamp, +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pact.v3.generators import ( + Generator, + date_time, + random_boolean, + random_decimal, + random_int, + random_string, ) +from pact.v3.generators import date as date_generator +from pact.v3.generators import regex as regex_generator +from pact.v3.generators import time as time_generator +from pact.v3.match.matchers import ConcreteMatcher, Matcher + +if TYPE_CHECKING: + from pact.v3.match.types import MatchType + + +def integer( + value: int | None = None, + min_val: int | None = None, + max_val: int | None = None, +) -> Matcher: + """ + Returns a matcher that matches an integer value. + + Args: + value: + The value to return when running a consumer test. Defaults to None. + min_val: + The minimum value of the integer to generate. Defaults to None. + max_val: + The maximum value of the integer to generate. Defaults to None. + """ + return ConcreteMatcher( + "integer", + value, + generator=random_int(min_val, max_val), + ) + + +def decimal(value: float | None = None, digits: int | None = None) -> Matcher: + """ + Returns a matcher that matches a decimal value. + + Args: + value: + The value to return when running a consumer test. Defaults to None. + digits: + The number of decimal digits to generate. Defaults to None. + """ + return ConcreteMatcher("decimal", value, generator=random_decimal(digits)) + + +def number( + value: float | None = None, + min_val: float | None = None, + max_val: float | None = None, + digits: int | None = None, +) -> Matcher: + """ + Returns a matcher that matches a number value. + + If all arguments are None, a random_decimal generator will be used. + If value argument is an integer or either min_val or max_val are provided, + a random_int generator will be used. + + Args: + value: + The value to return when running a consumer test. + Defaults to None. + min_val: + The minimum value of the number to generate. Only used when + value is an integer. Defaults to None. + max_val: + The maximum value of the number to generate. Only used when + value is an integer. Defaults to None. + digits: + The number of decimal digits to generate. Only used when + value is a float. Defaults to None. + """ + if min_val is not None and digits is not None: + msg = "min_val and digits cannot be used together" + raise ValueError(msg) + + if isinstance(value, int) or any(v is not None for v in [min_val, max_val]): + generator = random_int(min_val, max_val) + else: + generator = random_decimal(digits) + return ConcreteMatcher("number", value, generator=generator) + + +def string( + value: str | None = None, + size: int | None = None, + generator: Generator | None = None, +) -> Matcher: + """ + Returns a matcher that matches a string value. + + Args: + value: + The value to return when running a consumer test. Defaults to None. + size: + The size of the string to generate. Defaults to None. + generator: + The generator to use when generating the value. Defaults to None. If + no generator is provided and value is not provided, a random string + generator will be used. + """ + if generator is not None: + return ConcreteMatcher("type", value, generator=generator, force_generator=True) + return ConcreteMatcher("type", value, generator=random_string(size)) + + +def boolean(*, value: bool | None = True) -> Matcher: + """ + Returns a matcher that matches a boolean value. + + Args: + value: + The value to return when running a consumer test. Defaults to True. + """ + return ConcreteMatcher("boolean", value, generator=random_boolean()) + + +def date(format_str: str, value: str | None = None) -> Matcher: + """ + Returns a matcher that matches a date value. + + Args: + format_str: + The format of the date. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for details on the format string. + value: + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "date", value, format=format_str, generator=date_generator(format_str) + ) + + +def time(format_str: str, value: str | None = None) -> Matcher: + """ + Returns a matcher that matches a time value. + + Args: + format_str: + The format of the time. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for details on the format string. + value: + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "time", value, format=format_str, generator=time_generator(format_str) + ) + + +def timestamp(format_str: str, value: str | None = None) -> Matcher: + """ + Returns a matcher that matches a timestamp value. + + Args: + format_str: + The format of the timestamp. See + [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + for details on the format string. + value: + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "timestamp", + value, + format=format_str, + generator=date_time(format_str), + ) + + +def null() -> Matcher: + """ + Returns a matcher that matches a null value. + """ + return ConcreteMatcher("null") + + +def like( + value: MatchType, + min_count: int | None = None, + max_count: int | None = None, + generator: Generator | None = None, +) -> Matcher: + """ + Returns a matcher that matches the given template. + + Args: + value: + The template to match against. This can be a primitive value, a + dictionary, or a list and matching will be done by type. + min_count: + The minimum number of items that must match the value. Defaults to None. + max_count: + The maximum number of items that must match the value. Defaults to None. + generator: + The generator to use when generating the value. Defaults to None. + """ + return ConcreteMatcher( + "type", value, min=min_count, max=max_count, generator=generator + ) + + +def each_like( + value: MatchType, + min_count: int | None = 1, + max_count: int | None = None, +) -> Matcher: + """ + Returns a matcher that matches each item in an array against a given value. + + Note that the matcher will validate the array length be at least one. + Also, the argument passed will be used as a template to match against + each item in the array and generally should not itself be an array. + + Args: + value: + The value to match against. + min_count: + The minimum number of items that must match the value. Default is 1. + max_count: + The maximum number of items that must match the value. + """ + return ConcreteMatcher("type", [value], min=min_count, max=max_count) + + +def includes(value: str, generator: Generator | None = None) -> Matcher: + """ + Returns a matcher that matches a string that includes the given value. + + Args: + value: + The value to match against. + generator: + The generator to use when generating the value. Defaults to None. + """ + return ConcreteMatcher("include", value, generator=generator, force_generator=True) + + +def array_containing(variants: list[MatchType]) -> Matcher: + """ + Returns a matcher that matches the items in an array against a number of variants. + + Matching is successful if each variant occurs once in the array. Variants may be + objects containing matching rules. + + Args: + variants: + A list of variants to match against. + """ + return ConcreteMatcher("arrayContains", variants=variants) + + +def regex(regex: str, value: str | None = None) -> Matcher: + """ + Returns a matcher that matches a string against a regular expression. + + If no value is provided, a random string will be generated that matches + the regular expression. + + Args: + regex: + The regular expression to match against. + value: + The value to return when running a consumer test. Defaults to None. + """ + return ConcreteMatcher( + "regex", + value, + generator=regex_generator(regex), + regex=regex, + ) + + +def each_key_matches(value: MatchType, rules: Matcher | list[Matcher]) -> Matcher: + """ + Returns a matcher that matches each key in a dictionary against a set of rules. + + Args: + value: + The value to match against. + rules: + The matching rules to match against each key. + """ + if isinstance(rules, Matcher): + rules = [rules] + return ConcreteMatcher("eachKey", value, rules=rules) + + +def each_value_matches(value: MatchType, rules: Matcher | list[Matcher]) -> Matcher: + """ + Returns a matcher that matches each value in a dictionary against a set of rules. -__all__ = [ - "array_containing", - "boolean", - "date", - "decimal", - "each_key_matches", - "each_like", - "each_value_matches", - "integer", - "includes", - "like", - "number", - "null", - "regex", - "string", - "time", - "timestamp", - "Matcher", - "MatcherEncoder", -] + Args: + value: + The value to match against. + rules: + The matching rules to match against each value. + """ + if isinstance(rules, Matcher): + rules = [rules] + return ConcreteMatcher("eachValue", value, rules=rules) diff --git a/src/pact/v3/match/matchers.py b/src/pact/v3/match/matchers.py index b2af3c503..79fb83cb1 100644 --- a/src/pact/v3/match/matchers.py +++ b/src/pact/v3/match/matchers.py @@ -11,25 +11,11 @@ from abc import ABC, abstractmethod from json import JSONEncoder -from typing import Any, Dict, List, Literal, Mapping, Sequence, Tuple +from typing import TYPE_CHECKING, Any, Literal -from pact.v3.generators import ( - Generator, - date_time, - random_boolean, - random_decimal, - random_int, - random_string, -) -from pact.v3.generators import ( - date as date_generator, -) -from pact.v3.generators import ( - regex as regex_generator, -) -from pact.v3.generators import ( - time as time_generator, -) +if TYPE_CHECKING: + from pact.v3.generators import Generator + from pact.v3.match.types import AtomicType MatcherTypeV3 = Literal[ "equality", @@ -67,23 +53,12 @@ class Matcher(ABC): """ @abstractmethod - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """ Convert the matcher to a dictionary for json serialization. """ -AtomicType = str | int | float | bool | None -MatchType = ( - AtomicType - | Dict[AtomicType, AtomicType] - | List[AtomicType] - | Tuple[AtomicType] - | Sequence[AtomicType] - | Mapping[AtomicType, AtomicType] -) - - class ConcreteMatcher(Matcher): """ ConcreteMatcher class. @@ -122,11 +97,11 @@ def __init__( self.force_generator = force_generator self.extra_attrs = {k: v for k, v in kwargs.items() if v is not None} - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """ Convert the matcher to a dictionary for json serialization. """ - data: Dict[str, Any] = { + data: dict[str, Any] = { "pact:matcher:type": self.type, } data["value"] = self.value if self.value is not None else "" @@ -148,298 +123,3 @@ def default(self, o: Any) -> Any: # noqa: ANN401 if isinstance(o, Matcher): return o.to_dict() return super().default(o) - - -def integer( - value: int | None = None, - min_val: int | None = None, - max_val: int | None = None, -) -> Matcher: - """ - Returns a matcher that matches an integer value. - - Args: - value: - The value to return when running a consumer test. Defaults to None. - min_val: - The minimum value of the integer to generate. Defaults to None. - max_val: - The maximum value of the integer to generate. Defaults to None. - """ - return ConcreteMatcher( - "integer", - value, - generator=random_int(min_val, max_val), - ) - - -def decimal(value: float | None = None, digits: int | None = None) -> Matcher: - """ - Returns a matcher that matches a decimal value. - - Args: - value: - The value to return when running a consumer test. Defaults to None. - digits: - The number of decimal digits to generate. Defaults to None. - """ - return ConcreteMatcher("decimal", value, generator=random_decimal(digits)) - - -def number( - value: float | None = None, - min_val: float | None = None, - max_val: float | None = None, - digits: int | None = None, -) -> Matcher: - """ - Returns a matcher that matches a number value. - - If all arguments are None, a random_decimal generator will be used. - If value argument is an integer or either min_val or max_val are provided, - a random_int generator will be used. - - Args: - value: - The value to return when running a consumer test. - Defaults to None. - min_val: - The minimum value of the number to generate. Only used when - value is an integer. Defaults to None. - max_val: - The maximum value of the number to generate. Only used when - value is an integer. Defaults to None. - digits: - The number of decimal digits to generate. Only used when - value is a float. Defaults to None. - """ - if min_val is not None and digits is not None: - msg = "min_val and digits cannot be used together" - raise ValueError(msg) - - if isinstance(value, int) or any(v is not None for v in [min_val, max_val]): - generator = random_int(min_val, max_val) - else: - generator = random_decimal(digits) - return ConcreteMatcher("number", value, generator=generator) - - -def string( - value: str | None = None, - size: int | None = None, - generator: Generator | None = None, -) -> Matcher: - """ - Returns a matcher that matches a string value. - - Args: - value: - The value to return when running a consumer test. Defaults to None. - size: - The size of the string to generate. Defaults to None. - generator: - The generator to use when generating the value. Defaults to None. If - no generator is provided and value is not provided, a random string - generator will be used. - """ - if generator is not None: - return ConcreteMatcher("type", value, generator=generator, force_generator=True) - return ConcreteMatcher("type", value, generator=random_string(size)) - - -def boolean(*, value: bool | None = True) -> Matcher: - """ - Returns a matcher that matches a boolean value. - - Args: - value: - The value to return when running a consumer test. Defaults to True. - """ - return ConcreteMatcher("boolean", value, generator=random_boolean()) - - -def date(format_str: str, value: str | None = None) -> Matcher: - """ - Returns a matcher that matches a date value. - - Args: - format_str: - The format of the date. See - [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) - for details on the format string. - value: - The value to return when running a consumer test. Defaults to None. - """ - return ConcreteMatcher( - "date", value, format=format_str, generator=date_generator(format_str) - ) - - -def time(format_str: str, value: str | None = None) -> Matcher: - """ - Returns a matcher that matches a time value. - - Args: - format_str: - The format of the time. See - [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) - for details on the format string. - value: - The value to return when running a consumer test. Defaults to None. - """ - return ConcreteMatcher( - "time", value, format=format_str, generator=time_generator(format_str) - ) - - -def timestamp(format_str: str, value: str | None = None) -> Matcher: - """ - Returns a matcher that matches a timestamp value. - - Args: - format_str: - The format of the timestamp. See - [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) - for details on the format string. - value: - The value to return when running a consumer test. Defaults to None. - """ - return ConcreteMatcher( - "timestamp", - value, - format=format_str, - generator=date_time(format_str), - ) - - -def null() -> Matcher: - """ - Returns a matcher that matches a null value. - """ - return ConcreteMatcher("null") - - -def like( - value: MatchType, - min_count: int | None = None, - max_count: int | None = None, - generator: Generator | None = None, -) -> Matcher: - """ - Returns a matcher that matches the given template. - - Args: - value: - The template to match against. This can be a primitive value, a - dictionary, or a list and matching will be done by type. - min_count: - The minimum number of items that must match the value. Defaults to None. - max_count: - The maximum number of items that must match the value. Defaults to None. - generator: - The generator to use when generating the value. Defaults to None. - """ - return ConcreteMatcher( - "type", value, min=min_count, max=max_count, generator=generator - ) - - -def each_like( - value: MatchType, - min_count: int | None = 1, - max_count: int | None = None, -) -> Matcher: - """ - Returns a matcher that matches each item in an array against a given value. - - Note that the matcher will validate the array length be at least one. - Also, the argument passed will be used as a template to match against - each item in the array and generally should not itself be an array. - - Args: - value: - The value to match against. - min_count: - The minimum number of items that must match the value. Default is 1. - max_count: - The maximum number of items that must match the value. - """ - return ConcreteMatcher("type", [value], min=min_count, max=max_count) - - -def includes(value: str, generator: Generator | None = None) -> Matcher: - """ - Returns a matcher that matches a string that includes the given value. - - Args: - value: - The value to match against. - generator: - The generator to use when generating the value. Defaults to None. - """ - return ConcreteMatcher("include", value, generator=generator, force_generator=True) - - -def array_containing(variants: List[MatchType]) -> Matcher: - """ - Returns a matcher that matches the items in an array against a number of variants. - - Matching is successful if each variant occurs once in the array. Variants may be - objects containing matching rules. - - Args: - variants: - A list of variants to match against. - """ - return ConcreteMatcher("arrayContains", variants=variants) - - -def regex(regex: str, value: str | None = None) -> Matcher: - """ - Returns a matcher that matches a string against a regular expression. - - If no value is provided, a random string will be generated that matches - the regular expression. - - Args: - regex: - The regular expression to match against. - value: - The value to return when running a consumer test. Defaults to None. - """ - return ConcreteMatcher( - "regex", - value, - generator=regex_generator(regex), - regex=regex, - ) - - -def each_key_matches(value: MatchType, rules: Matcher | List[Matcher]) -> Matcher: - """ - Returns a matcher that matches each key in a dictionary against a set of rules. - - Args: - value: - The value to match against. - rules: - The matching rules to match against each key. - """ - if isinstance(rules, Matcher): - rules = [rules] - return ConcreteMatcher("eachKey", value, rules=rules) - - -def each_value_matches(value: MatchType, rules: Matcher | List[Matcher]) -> Matcher: - """ - Returns a matcher that matches each value in a dictionary against a set of rules. - - Args: - value: - The value to match against. - rules: - The matching rules to match against each value. - """ - if isinstance(rules, Matcher): - rules = [rules] - return ConcreteMatcher("eachValue", value, rules=rules) diff --git a/src/pact/v3/match/types.py b/src/pact/v3/match/types.py new file mode 100644 index 000000000..004f9d4f3 --- /dev/null +++ b/src/pact/v3/match/types.py @@ -0,0 +1,15 @@ +""" +Typing definitions for the matchers. +""" + +from typing import Mapping, Sequence + +AtomicType = str | int | float | bool | None +MatchType = ( + AtomicType + | dict[AtomicType, AtomicType] + | list[AtomicType] + | tuple[AtomicType] + | Sequence[AtomicType] + | Mapping[AtomicType, AtomicType] +) From 4cd67f5c775ccafcd7d45b2eedd611920f510949 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Fri, 20 Sep 2024 07:38:52 -0600 Subject: [PATCH 13/18] fix recursive typing issues --- src/pact/v3/match/__init__.py | 7 ++++--- src/pact/v3/match/matchers.py | 19 ++++--------------- src/pact/v3/match/types.py | 30 ++++++++++++++++++++++++------ 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index b6796c2b0..3257d3e24 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -17,7 +17,8 @@ from pact.v3.generators import date as date_generator from pact.v3.generators import regex as regex_generator from pact.v3.generators import time as time_generator -from pact.v3.match.matchers import ConcreteMatcher, Matcher +from pact.v3.match.matchers import ConcreteMatcher +from pact.v3.match.types import Matcher if TYPE_CHECKING: from pact.v3.match.types import MatchType @@ -61,8 +62,8 @@ def decimal(value: float | None = None, digits: int | None = None) -> Matcher: def number( value: float | None = None, - min_val: float | None = None, - max_val: float | None = None, + min_val: int | None = None, + max_val: int | None = None, digits: int | None = None, ) -> Matcher: """ diff --git a/src/pact/v3/match/matchers.py b/src/pact/v3/match/matchers.py index 79fb83cb1..c666d818d 100644 --- a/src/pact/v3/match/matchers.py +++ b/src/pact/v3/match/matchers.py @@ -9,13 +9,14 @@ from __future__ import annotations -from abc import ABC, abstractmethod from json import JSONEncoder from typing import TYPE_CHECKING, Any, Literal +from pact.v3.match.types import Matcher + if TYPE_CHECKING: from pact.v3.generators import Generator - from pact.v3.match.types import AtomicType + from pact.v3.match.types import MatchType MatcherTypeV3 = Literal[ "equality", @@ -47,18 +48,6 @@ ) -class Matcher(ABC): - """ - Matcher interface for exporting. - """ - - @abstractmethod - def to_dict(self) -> dict[str, Any]: - """ - Convert the matcher to a dictionary for json serialization. - """ - - class ConcreteMatcher(Matcher): """ ConcreteMatcher class. @@ -71,7 +60,7 @@ def __init__( generator: Generator | None = None, *, force_generator: bool | None = False, - **kwargs: AtomicType, + **kwargs: MatchType, ) -> None: """ Initialize the matcher class. diff --git a/src/pact/v3/match/types.py b/src/pact/v3/match/types.py index 004f9d4f3..6c30df630 100644 --- a/src/pact/v3/match/types.py +++ b/src/pact/v3/match/types.py @@ -2,14 +2,32 @@ Typing definitions for the matchers. """ -from typing import Mapping, Sequence +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Mapping, Sequence AtomicType = str | int | float | bool | None + + +class Matcher(ABC): + """ + Matcher interface for exporting. + """ + + @abstractmethod + def to_dict(self) -> dict[str, Any]: + """ + Convert the matcher to a dictionary for json serialization. + """ + + MatchType = ( AtomicType - | dict[AtomicType, AtomicType] - | list[AtomicType] - | tuple[AtomicType] - | Sequence[AtomicType] - | Mapping[AtomicType, AtomicType] + | Matcher + | dict[AtomicType, "MatchType"] + | list["MatchType"] + | tuple["MatchType"] + | Sequence["MatchType"] + | Mapping[AtomicType, "MatchType"] ) From fe4d4afb4e8db9e04714b9b9d9352782a0802454 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Fri, 20 Sep 2024 07:44:36 -0600 Subject: [PATCH 14/18] fix issues importing MatcherEncoder --- src/pact/v3/interaction/_base.py | 4 +++- src/pact/v3/interaction/_http_interaction.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index 70ebfe641..61d3a40a2 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -16,11 +16,13 @@ from typing import TYPE_CHECKING, Any, Literal, overload import pact.v3.ffi -from pact.v3.match import Matcher, MatcherEncoder +from pact.v3.match.matchers import MatcherEncoder if TYPE_CHECKING: from pathlib import Path + from pact.v3.match import Matcher + try: from typing import Self except ImportError: diff --git a/src/pact/v3/interaction/_http_interaction.py b/src/pact/v3/interaction/_http_interaction.py index c1c682b6e..ab5fc7fca 100644 --- a/src/pact/v3/interaction/_http_interaction.py +++ b/src/pact/v3/interaction/_http_interaction.py @@ -10,7 +10,8 @@ import pact.v3.ffi from pact.v3.interaction._base import Interaction -from pact.v3.match import Matcher, MatcherEncoder +from pact.v3.match import Matcher +from pact.v3.match.matchers import MatcherEncoder if TYPE_CHECKING: try: From 547fbe16ac0342c849f2ccbcb9769d2efeece064 Mon Sep 17 00:00:00 2001 From: valkolovos Date: Fri, 20 Sep 2024 09:48:27 -0600 Subject: [PATCH 15/18] fixing typing issues for python3.9 --- src/pact/v3/match/matchers.py | 12 ++++++------ src/pact/v3/match/types.py | 22 +++++++++++----------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/pact/v3/match/matchers.py b/src/pact/v3/match/matchers.py index c666d818d..3fed6a2f4 100644 --- a/src/pact/v3/match/matchers.py +++ b/src/pact/v3/match/matchers.py @@ -10,7 +10,7 @@ from __future__ import annotations from json import JSONEncoder -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, Union from pact.v3.match.types import Matcher @@ -36,16 +36,16 @@ "arrayContains", ] -MatcherTypeV4 = ( - MatcherTypeV3 - | Literal[ +MatcherTypeV4 = Union[ + MatcherTypeV3, + Literal[ "statusCode", "notEmpty", "semver", "eachKey", "eachValue", - ] -) + ], +] class ConcreteMatcher(Matcher): diff --git a/src/pact/v3/match/types.py b/src/pact/v3/match/types.py index 6c30df630..651278768 100644 --- a/src/pact/v3/match/types.py +++ b/src/pact/v3/match/types.py @@ -5,9 +5,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any, Mapping, Sequence +from typing import Any, Mapping, Sequence, Union -AtomicType = str | int | float | bool | None +AtomicType = Union[str, int, float, bool, None] class Matcher(ABC): @@ -22,12 +22,12 @@ def to_dict(self) -> dict[str, Any]: """ -MatchType = ( - AtomicType - | Matcher - | dict[AtomicType, "MatchType"] - | list["MatchType"] - | tuple["MatchType"] - | Sequence["MatchType"] - | Mapping[AtomicType, "MatchType"] -) +MatchType = Union[ + AtomicType, + Matcher, + dict[AtomicType, "MatchType"], + list["MatchType"], + tuple["MatchType"], + Sequence["MatchType"], + Mapping[AtomicType, "MatchType"], +] From 0dfc73c510541d4993168886818373b2f5a3291f Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 26 Sep 2024 13:09:51 +1000 Subject: [PATCH 16/18] refactor: split types into stub The `match.types` module only provides typing information; therefore, it makes sense to have a minimal definition in `types.py` and to create a much more complex `types.pyi` stub. The stub is not evaluated at runtime, but is parsed by type checkers. As an additional benefit for the stubs, there's no need to conditionally import modules which may or may not be present. Signed-off-by: JP-Ellis --- src/pact/v3/match/types.py | 33 +++++---------------------------- src/pact/v3/match/types.pyi | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 28 deletions(-) create mode 100644 src/pact/v3/match/types.pyi diff --git a/src/pact/v3/match/types.py b/src/pact/v3/match/types.py index 651278768..393f8ae22 100644 --- a/src/pact/v3/match/types.py +++ b/src/pact/v3/match/types.py @@ -2,32 +2,9 @@ Typing definitions for the matchers. """ -from __future__ import annotations +from typing import Any, TypeAlias -from abc import ABC, abstractmethod -from typing import Any, Mapping, Sequence, Union - -AtomicType = Union[str, int, float, bool, None] - - -class Matcher(ABC): - """ - Matcher interface for exporting. - """ - - @abstractmethod - def to_dict(self) -> dict[str, Any]: - """ - Convert the matcher to a dictionary for json serialization. - """ - - -MatchType = Union[ - AtomicType, - Matcher, - dict[AtomicType, "MatchType"], - list["MatchType"], - tuple["MatchType"], - Sequence["MatchType"], - Mapping[AtomicType, "MatchType"], -] +Matchable: TypeAlias = Any +""" +All supported matchable types. +""" diff --git a/src/pact/v3/match/types.pyi b/src/pact/v3/match/types.pyi new file mode 100644 index 000000000..eefda39c6 --- /dev/null +++ b/src/pact/v3/match/types.pyi @@ -0,0 +1,36 @@ +from collections.abc import Collection, Mapping, Sequence +from collections.abc import Set as AbstractSet +from decimal import Decimal +from fractions import Fraction +from typing import TypeAlias + +from pydantic import BaseModel + +_BaseMatchable: TypeAlias = ( + int | float | complex | bool | str | bytes | bytearray | memoryview | None +) +""" +Base types that generally can't be further decomposed. + +See: https://docs.python.org/3/library/stdtypes.html +""" + +_ContainerMatchable: TypeAlias = ( + Sequence[Matchable] + | AbstractSet[Matchable] + | Mapping[_BaseMatchable, Matchable] + | Collection[Matchable] +) +""" +Containers that can be further decomposed. + +These are defined based on the abstract base classes defined in the +[`collections.abc`][collections.abc] module. +""" + +_ExtraMatchable: TypeAlias = BaseModel | Decimal | Fraction + +Matchable: TypeAlias = _BaseMatchable | _ContainerMatchable | _ExtraMatchable +""" +All supported matchable types. +""" From 2f33caf9ecd1288046c01e32db476becb777906b Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 26 Sep 2024 13:38:35 +1000 Subject: [PATCH 17/18] feat: add matchable typevar This is the TypeVar equivalent of the Matchable union type. Signed-off-by: JP-Ellis --- src/pact/v3/match/types.py | 11 ++++++++++- src/pact/v3/match/types.pyi | 26 +++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/pact/v3/match/types.py b/src/pact/v3/match/types.py index 393f8ae22..90b06c9f1 100644 --- a/src/pact/v3/match/types.py +++ b/src/pact/v3/match/types.py @@ -2,9 +2,18 @@ Typing definitions for the matchers. """ -from typing import Any, TypeAlias +from typing import Any, TypeAlias, TypeVar + +# Make _MatchableT explicitly public, despite ultimately only being used +# privately. +__all__ = ["Matchable", "_MatchableT"] Matchable: TypeAlias = Any """ All supported matchable types. """ + +_MatchableT = TypeVar("_MatchableT") +""" +Matchable type variable. +""" diff --git a/src/pact/v3/match/types.pyi b/src/pact/v3/match/types.pyi index eefda39c6..e3fcd6dfd 100644 --- a/src/pact/v3/match/types.pyi +++ b/src/pact/v3/match/types.pyi @@ -2,10 +2,14 @@ from collections.abc import Collection, Mapping, Sequence from collections.abc import Set as AbstractSet from decimal import Decimal from fractions import Fraction -from typing import TypeAlias +from typing import TypeAlias, TypeVar from pydantic import BaseModel +# Make _MatchableT explicitly public, despite ultimately only being used +# privately. +__all__ = ["Matchable", "_MatchableT"] + _BaseMatchable: TypeAlias = ( int | float | complex | bool | str | bytes | bytearray | memoryview | None ) @@ -34,3 +38,23 @@ Matchable: TypeAlias = _BaseMatchable | _ContainerMatchable | _ExtraMatchable """ All supported matchable types. """ + +_MatchableT = TypeVar( + "_MatchableT", + int, + float, + complex, + bool, + str, + bytes, + bytearray, + memoryview, + None, + Sequence[Matchable], + AbstractSet[Matchable], + Mapping[_BaseMatchable, Matchable], + Collection[Matchable], + BaseModel, + Decimal, + Fraction, +) From acf17105eb1f1290036c63c6203af712d0918d3a Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 26 Sep 2024 13:58:31 +1000 Subject: [PATCH 18/18] refactor: matcher Some minor renaming. Instead of `ConcreteMatcher`, prefer `GenericMatcher` as it leaves room for other mathcers to be created (e.g. `BooleanMatcher`, `BaseModelmatcher`, etc.). Secondly, made the matcher compatible with both the Integration JSON format and Matching Rules format (and created two separate encoders to go with these). Signed-off-by: JP-Ellis --- src/pact/v3/match/matcher.py | 333 ++++++++++++++++++++++++++++++++++ src/pact/v3/match/matchers.py | 114 ------------ 2 files changed, 333 insertions(+), 114 deletions(-) create mode 100644 src/pact/v3/match/matcher.py delete mode 100644 src/pact/v3/match/matchers.py diff --git a/src/pact/v3/match/matcher.py b/src/pact/v3/match/matcher.py new file mode 100644 index 000000000..439d21e58 --- /dev/null +++ b/src/pact/v3/match/matcher.py @@ -0,0 +1,333 @@ +""" +Matching functionality for Pact. + +Matchers are used in Pact to allow for more flexible matching of data. While the +consumer defines the expected request and response, there are circumstances +where the provider may return dynamically generated data. In these cases, the +consumer should use a matcher to define the expected data. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from itertools import chain +from json import JSONEncoder +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Literal, + Union, +) + +from pact.v3.match.types import Matchable, _MatchableT + +if TYPE_CHECKING: + from collections.abc import Mapping + + from pact.v3.generate import Generator + +_MatcherTypeV3 = Literal[ + "equality", + "regex", + "type", + "include", + "integer", + "decimal", + "number", + "timestamp", + "time", + "date", + "null", + "boolean", + "contentType", + "values", + "arrayContains", +] +""" +Matchers defined in the V3 specification. +""" + +_MatcherTypeV4 = Literal[ + "statusCode", + "notEmpty", + "semver", + "eachKey", + "eachValue", +] +""" +Matchers defined in the V4 specification. +""" + +MatcherType = Union[_MatcherTypeV3, _MatcherTypeV4] +""" +All supported matchers. +""" + + +class Unset: + """ + Special type to represent an unset value. + + Typically, the value `None` is used to represent an unset value. However, we + need to differentiate between a null value and an unset value. For example, + a matcher may have a value of `None`, which is different from a matcher + having no value at all. This class is used to represent the latter. + """ + + +_Unset = Unset() + + +class Matcher(ABC, Generic[_MatchableT]): + """ + Abstract matcher. + + In Pact, a matcher is used to define how a value should be compared. This + allows for more flexible matching of data, especially when the provider + returns dynamically generated data. + + This class is abstract and should not be used directly. Instead, use one of + the concrete matcher classes. Alternatively, you can create your own matcher + by subclassing this class. + + The matcher provides methods to convert into an integration JSON object and + a matching rule. These methods are used internally by the Pact library when + generating the Pact file. + """ + + @abstractmethod + 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. + """ + + @abstractmethod + 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. + """ + + +class GenericMatcher(Matcher[_MatchableT]): + """ + Generic matcher. + + In Pact, a matcher is used to define how a value should be compared. This + allows for more flexible matching of data, especially when the provider + returns dynamically generated data. + """ + + def __init__( # noqa: PLR0913 + self, + type: MatcherType, # noqa: A002 + /, + value: _MatchableT | Unset = _Unset, + generator: Generator | None = None, + extra_fields: Mapping[str, Matchable] | None = None, + integration_fields: Mapping[str, Matchable] | None = None, + matching_rule_fields: Mapping[str, Matchable] | None = None, + **kwargs: Matchable, + ) -> None: + """ + Initialize the matcher. + + Args: + type: + The type of the matcher. + + value: + The value to match. If not provided, the Pact library will + generate a value based on the matcher type (or use the generator + if provided). To ensure reproducibility, it is _highly_ + recommended to provide a value when creating a matcher. + + generator: + The generator to use when generating the value. The generator + will generally only be used if value is not provided. + + extra_fields: + Additional configuration elements to pass to the matcher. These + fields will be used when converting the matcher to an + integration JSON object or a matching rule. + + integration_fields: + Additional configuration elements to pass to the matcher when + converting it to an integration JSON object. + + matching_rule_fields: + Additional configuration elements to pass to the matcher when + converting it to a matching rule. + + **kwargs: + Alternative way to define extra fields. See the `extra_fields` + argument for more information. + """ + self.type = type + """ + The type of the matcher. + """ + + self.value: _MatchableT | Unset = value + """ + Default value used by Pact when executing tests. + """ + + self.generator = generator + """ + Generator used to generate a value when the value is not provided. + """ + + self._integration_fields = integration_fields or {} + self._matching_rule_fields = matching_rule_fields or {} + self._extra_fields = dict(chain((extra_fields or {}).items(), kwargs.items())) + + def has_value(self) -> bool: + """ + Check if the matcher has a value. + """ + return not isinstance(self.value, Unset) + + def extra_fields(self) -> dict[str, Matchable]: + """ + Return any extra fields for the matcher. + + These fields are added to the matcher when it is converted to an + integration JSON object or a matching rule. + """ + return self._extra_fields + + def extra_integration_fields(self) -> dict[str, Matchable]: + """ + Return any extra fields for the integration JSON object. + + These fields are added to the matcher when it is converted to an + integration JSON object. + + If there is any overlap in the keys between this method and + [`extra_fields`](#extra_fields), the values from this method will be + used. + """ + return {**self.extra_fields(), **self._integration_fields} + + def extra_matching_rule_fields(self) -> dict[str, Matchable]: + """ + Return any extra fields for the matching rule. + + These fields are added to the matcher when it is converted to a matching + rule. + + If there is any overlap in the keys between this method and + [`extra_fields`](#extra_fields), the values from this method will be + used. + """ + return {**self.extra_fields(), **self._matching_rule_fields} + + def to_integration_json(self) -> dict[str, Matchable]: + """ + 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: + dict[str, Any]: + The matcher as an integration JSON object. + """ + return { + "pact:matcher:type": self.type, + **({"value": self.value} if not isinstance(self.value, Unset) else {}), + **( + self.generator.to_integration_json() + if self.generator is not None + else {} + ), + **self.extra_integration_fields(), + } + + 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: + dict[str, Any]: + The matcher as a matching rule. + """ + return { + "match": self.type, + **({"value": self.value} if not isinstance(self.value, Unset) else {}), + **(self.generator.to_matching_rule() if self.generator is not None else {}), + **self.extra_fields(), + **self.extra_matching_rule_fields(), + } + + +class MatchingRuleJSONEncoder(JSONEncoder): + """ + JSON encoder class for matching rules. + + This class is used to encode matching rules to JSON. + """ + + def default(self, o: Any) -> Any: # noqa: ANN401 + """ + Encode the object to JSON. + """ + if isinstance(o, Matcher): + return o.to_matching_rule() + return super().default(o) + + +class IntegrationJSONEncoder(JSONEncoder): + """ + JSON encoder class for integration JSON objects. + + This class is used to encode integration JSON objects to JSON. + """ + + def default(self, o: Any) -> Any: # noqa: ANN401 + """ + Encode the object to JSON. + """ + if isinstance(o, Matcher): + return o.to_integration_json() + return super().default(o) diff --git a/src/pact/v3/match/matchers.py b/src/pact/v3/match/matchers.py deleted file mode 100644 index 3fed6a2f4..000000000 --- a/src/pact/v3/match/matchers.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -Matching functionality for Pact. - -Matchers are used in Pact to allow for more flexible matching of data. While the -consumer defines the expected request and response, there are circumstances -where the provider may return dynamically generated data. In these cases, the -consumer should use a matcher to define the expected data. -""" - -from __future__ import annotations - -from json import JSONEncoder -from typing import TYPE_CHECKING, Any, Literal, Union - -from pact.v3.match.types import Matcher - -if TYPE_CHECKING: - from pact.v3.generators import Generator - from pact.v3.match.types import MatchType - -MatcherTypeV3 = Literal[ - "equality", - "regex", - "type", - "include", - "integer", - "decimal", - "number", - "timestamp", - "time", - "date", - "null", - "boolean", - "contentType", - "values", - "arrayContains", -] - -MatcherTypeV4 = Union[ - MatcherTypeV3, - Literal[ - "statusCode", - "notEmpty", - "semver", - "eachKey", - "eachValue", - ], -] - - -class ConcreteMatcher(Matcher): - """ - ConcreteMatcher class. - """ - - def __init__( - self, - matcher_type: MatcherTypeV4, - value: Any | None = None, # noqa: ANN401 - generator: Generator | None = None, - *, - force_generator: bool | None = False, - **kwargs: MatchType, - ) -> None: - """ - Initialize the matcher class. - - Args: - matcher_type: - The type of the matcher. - value: - The value to return when running a consumer test. - Defaults to None. - generator: - The generator to use when generating the value. The generator will - generally only be used if value is not provided. Defaults to None. - force_generator: - If True, the generator will be used to generate a value even if - a value is provided. Defaults to False. - **kwargs: - Additional configuration elements to pass to the matcher. - """ - self.type = matcher_type - self.value = value - self.generator = generator - self.force_generator = force_generator - self.extra_attrs = {k: v for k, v in kwargs.items() if v is not None} - - def to_dict(self) -> dict[str, Any]: - """ - Convert the matcher to a dictionary for json serialization. - """ - data: dict[str, Any] = { - "pact:matcher:type": self.type, - } - data["value"] = self.value if self.value is not None else "" - if self.generator is not None and (self.value is None or self.force_generator): - data.update(self.generator.to_dict()) - data.update(self.extra_attrs) - return data - - -class MatcherEncoder(JSONEncoder): - """ - Matcher encoder class for json serialization. - """ - - def default(self, o: Any) -> Any: # noqa: ANN401 - """ - Encode the object to json. - """ - if isinstance(o, Matcher): - return o.to_dict() - return super().default(o)