From e77b5620d3fe1bd3eb7b05a4553fdd7d83b6713d Mon Sep 17 00:00:00 2001 From: valkolovos Date: Tue, 13 Aug 2024 09:17:16 -0600 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 1df0319aee10e457b5f533ef729c4e9e2ba641dc Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 26 Sep 2024 13:58:31 +1000 Subject: [PATCH 18/43] 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/interaction/_base.py | 4 +- src/pact/v3/interaction/_http_interaction.py | 8 +- src/pact/v3/match/matcher.py | 333 +++++++++++++++++++ src/pact/v3/match/matchers.py | 114 ------- 4 files changed, 339 insertions(+), 120 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/interaction/_base.py b/src/pact/v3/interaction/_base.py index 61d3a40a2..d7756b1c8 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.match.matchers import MatcherEncoder +from pact.v3.match.matcher import IntegrationJSONEncoder if TYPE_CHECKING: from pathlib import Path @@ -272,7 +272,7 @@ def with_body( if body and isinstance(body, str): body_str = body else: - body_str = json.dumps(body, cls=MatcherEncoder) + body_str = json.dumps(body, cls=IntegrationJSONEncoder) 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 ab5fc7fca..18e2f2823 100644 --- a/src/pact/v3/interaction/_http_interaction.py +++ b/src/pact/v3/interaction/_http_interaction.py @@ -11,7 +11,7 @@ import pact.v3.ffi from pact.v3.interaction._base import Interaction from pact.v3.match import Matcher -from pact.v3.match.matchers import MatcherEncoder +from pact.v3.match.matcher import IntegrationJSONEncoder if TYPE_CHECKING: try: @@ -110,7 +110,7 @@ def with_request(self, method: str, path: str | Matcher) -> Self: Path for the request. """ if isinstance(path, Matcher): - path_str = json.dumps(path, cls=MatcherEncoder) + path_str = json.dumps(path, cls=IntegrationJSONEncoder) else: path_str = path pact.v3.ffi.with_request(self._handle, method, path_str) @@ -216,7 +216,7 @@ def with_header( 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) + value_str: str = json.dumps(value, cls=IntegrationJSONEncoder) else: value_str = value pact.v3.ffi.with_header_v2( @@ -414,7 +414,7 @@ def with_query_parameter(self, name: str, value: str | dict | Matcher) -> Self: index = self._parameter_indices[name] self._parameter_indices[name] += 1 if not isinstance(value, str): - value_str: str = json.dumps(value, cls=MatcherEncoder) + value_str: str = json.dumps(value, cls=IntegrationJSONEncoder) else: value_str = value pact.v3.ffi.with_query_parameter_v2( 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) From 370aa3ae5cd243dd8edb163c9051b82b38426916 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 30 Sep 2024 18:19:30 +1000 Subject: [PATCH 19/43] chore: split stdlib and 3rd party types Signed-off-by: JP-Ellis --- src/pact/v3/match/types.pyi | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/pact/v3/match/types.pyi b/src/pact/v3/match/types.pyi index e3fcd6dfd..6bf8b7745 100644 --- a/src/pact/v3/match/types.pyi +++ b/src/pact/v3/match/types.pyi @@ -1,5 +1,6 @@ from collections.abc import Collection, Mapping, Sequence from collections.abc import Set as AbstractSet +from datetime import date, datetime, time from decimal import Decimal from fractions import Fraction from typing import TypeAlias, TypeVar @@ -32,15 +33,26 @@ These are defined based on the abstract base classes defined in the [`collections.abc`][collections.abc] module. """ -_ExtraMatchable: TypeAlias = BaseModel | Decimal | Fraction +_StdlibMatchable: TypeAlias = Decimal | Fraction | date | time | datetime +""" +Standard library types. +""" + +_ExtraMatchable: TypeAlias = BaseModel +""" +Additional matchable types, typically from third-party libraries. +""" -Matchable: TypeAlias = _BaseMatchable | _ContainerMatchable | _ExtraMatchable +Matchable: TypeAlias = ( + _BaseMatchable | _ContainerMatchable | _StdlibMatchable | _ExtraMatchable +) """ All supported matchable types. """ _MatchableT = TypeVar( "_MatchableT", + # BaseMatchable int, float, complex, @@ -50,11 +62,17 @@ _MatchableT = TypeVar( bytearray, memoryview, None, + # ContainerMatchable Sequence[Matchable], AbstractSet[Matchable], Mapping[_BaseMatchable, Matchable], Collection[Matchable], - BaseModel, + # StdlibMatchable Decimal, Fraction, + date, + time, + datetime, + # ExtraMatchable + BaseModel, ) From b44a238f4841a72abb7a224e40df2f9f24510394 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 30 Sep 2024 18:20:04 +1000 Subject: [PATCH 20/43] docs: add module docstring Signed-off-by: JP-Ellis --- src/pact/v3/match/__init__.py | 37 ++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index 3257d3e24..3b570110f 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -1,5 +1,40 @@ """ -Matchers module. +Matching functionality. + +This module provides the functionality to define matching rules to be used +within a Pact contract. These rules define the expected content of the data +being exchanged in a way that is more flexible than a simple equality check. + +As an example, a contract may define how a new record is to be created through +a POST request. The consumer would define the new information to be sent, and +the expected response. The response may contain additional data added by the +provider, such as an ID and a creation timestamp. The contract would define +that the ID is of a specific format (e.g., an integer or a UUID), and that the +creation timestamp is ISO 8601 formatted. + +!!! warning + + Do not import functions directly from this module. Instead, import the + `match` module and use the functions from there: + + ```python + # Good + from pact.v3 import match + + match.int(...) + + # Bad + from pact.v3.match import int + + int(...) + ``` + +A number of functions in this module are named after the types they match +(e.g., `int`, `str`, `bool`). These functions will have aliases as well for +better interoperability with the rest of the Pact ecosystem. It is important +to note that these functions will shadow the built-in types if imported directly +from this module. This is why we recommend importing the `match` module and +using the functions from there. """ from __future__ import annotations From 966200823cbc5385cc3ba2ca6e16c35bc0a88746 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 30 Sep 2024 18:22:49 +1000 Subject: [PATCH 21/43] refactor: rename generators to generate Signed-off-by: JP-Ellis --- src/pact/v3/{generators => generate}/__init__.py | 2 +- src/pact/v3/{generators => generate}/generators.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/pact/v3/{generators => generate}/__init__.py (93%) rename src/pact/v3/{generators => generate}/generators.py (99%) diff --git a/src/pact/v3/generators/__init__.py b/src/pact/v3/generate/__init__.py similarity index 93% rename from src/pact/v3/generators/__init__.py rename to src/pact/v3/generate/__init__.py index 605407173..ebe17fc93 100644 --- a/src/pact/v3/generators/__init__.py +++ b/src/pact/v3/generate/__init__.py @@ -2,7 +2,7 @@ Generator module. """ -from pact.v3.generators.generators import ( +from pact.v3.generate.generators import ( Generator, GeneratorTypes, GeneratorTypeV3, diff --git a/src/pact/v3/generators/generators.py b/src/pact/v3/generate/generators.py similarity index 99% rename from src/pact/v3/generators/generators.py rename to src/pact/v3/generate/generators.py index f83f58763..ebb847c01 100644 --- a/src/pact/v3/generators/generators.py +++ b/src/pact/v3/generate/generators.py @@ -114,7 +114,7 @@ def random_string(size: Optional[int] = None) -> Generator: Create a random string generator. Args: - size (Optional[int], optional): + size: The size of the string to generate. """ return ConcreteGenerator("RandomString", {"size": size}) From 57aeb3b6a5905e4a9433f9619f3a301ce2596f66 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 30 Sep 2024 18:24:27 +1000 Subject: [PATCH 22/43] feat: add strftime to java date format converter Signed-off-by: JP-Ellis --- src/pact/v3/util.py | 142 ++++++++++++++++++++++++++++++++++++++++++ tests/v3/test_util.py | 36 +++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/pact/v3/util.py create mode 100644 tests/v3/test_util.py diff --git a/src/pact/v3/util.py b/src/pact/v3/util.py new file mode 100644 index 000000000..64a178313 --- /dev/null +++ b/src/pact/v3/util.py @@ -0,0 +1,142 @@ +""" +Utility functions for Pact. + +This module defines a number of utility functions that are used in specific +contexts within the Pact library. These functions are not intended to be +used directly by consumers of the library, but are still made available for +reference. +""" + +import warnings + +_PYTHON_FORMAT_TO_JAVA_DATETIME = { + "a": "EEE", + "A": "EEEE", + "b": "MMM", + "B": "MMMM", + # c is locale dependent, so we can't convert it directly. + "d": "dd", + "f": "SSSSSS", + "G": "YYYY", + "H": "HH", + "I": "hh", + "j": "DDD", + "m": "MM", + "M": "mm", + "p": "a", + "S": "ss", + "u": "u", + "U": "ww", + "V": "ww", + # w is 0-indexed in Python, but 1-indexed in Java. + "W": "ww", + # x is locale dependent, so we can't convert it directly. + # X is locale dependent, so we can't convert it directly. + "y": "yy", + "Y": "yyyy", + "z": "Z", + "Z": "z", + "%": "%", + ":z": "XXX", +} + + +def strftime_to_simple_date_format(python_format: str) -> str: + """ + Convert a Python datetime format string to Java SimpleDateFormat format. + + Python uses [`strftime` + codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) + which are ultimately based on the C `strftime` function. Java uses + [`SimpleDateFormat` + codes](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + which generally have corresponding codes, but with some differences. + + Note that this function strictly supports codes explicitly defined in the + Python documentation. Locale-dependent codes are not supported, and codes + supported by the underlying C library but not Python are not supported. For + examples, `%c`, `%x`, and `%X` are not supported as they are locale + dependent, and `%D` is not supported as it is not part of the Python + documentation (even though it may be supported by the underlying C and + therefore work in some Python implementations). + + Args: + python_format: + The Python datetime format string to convert. + + Returns: + The equivalent Java SimpleDateFormat format string. + """ + # Each Python format code is exactly two characters long, so we can + # safely iterate through the string. + idx = 0 + result: str = "" + escaped = False + + while idx < len(python_format): + c = python_format[idx] + idx += 1 + + if c == "%": + c = python_format[idx] + if escaped: + result += "'" + escaped = False + result += _format_code_to_java_format(c) + # Increment another time to skip the second character of the + # Python format code. + idx += 1 + continue + + if c == "'": + # In Java, single quotes are used to escape characters. + # To insert a single quote, we need to insert two single quotes. + # It doesn't matter if we're in an escape sequence or not, as + # Java treats them the same. + result += "''" + continue + + if not escaped and c.isalpha(): + result += "'" + escaped = True + result += c + + if escaped: + result += "'" + return result + + +def _format_code_to_java_format(code: str) -> str: + """ + Convert a single Python format code to a Java SimpleDateFormat format. + + Args: + code: + The Python format code to convert, without the leading `%`. This + will typically be a single character, but may be two characters + for some codes. + + Returns: + The equivalent Java SimpleDateFormat format string. + """ + if code in ["U", "V", "W"]: + warnings.warn( + f"The Java equivalent for `%{code}` is locale dependent.", + stacklevel=3, + ) + + # The following are locale-dependent, and aren't directly convertible. + if code in ["c", "x", "X"]: + msg = f"Cannot convert locale-dependent Python format code `%{code}` to Java" + raise ValueError(msg) + + # The following codes simply do not have a direct equivalent in Java. + if code in ["w"]: + msg = f"Python format code `%{code}` is not supported in Java" + raise ValueError(msg) + + if code in _PYTHON_FORMAT_TO_JAVA_DATETIME: + return _PYTHON_FORMAT_TO_JAVA_DATETIME[code] + + msg = f"Unsupported Python format code `%{code}`" + raise ValueError(msg) diff --git a/tests/v3/test_util.py b/tests/v3/test_util.py new file mode 100644 index 000000000..1ef039397 --- /dev/null +++ b/tests/v3/test_util.py @@ -0,0 +1,36 @@ +""" +Tests of pact.v3.util functions. +""" + +import pytest + +from pact.v3.util import strftime_to_simple_date_format + + +def test_convert_python_to_java_datetime_format_basic() -> None: + assert strftime_to_simple_date_format("%Y-%m-%d") == "yyyy-MM-dd" + assert strftime_to_simple_date_format("%H:%M:%S") == "HH:mm:ss" + + +def test_convert_python_to_java_datetime_format_with_unsupported_code() -> None: + with pytest.raises( + ValueError, + match="Cannot convert locale-dependent Python format code `%c` to Java", + ): + strftime_to_simple_date_format("%c") + + +def test_convert_python_to_java_datetime_format_with_warning() -> None: + with pytest.warns( + UserWarning, match="The Java equivalent for `%U` is locale dependent." + ): + assert strftime_to_simple_date_format("%U") == "ww" + + +def test_convert_python_to_java_datetime_format_with_escape_characters() -> None: + assert strftime_to_simple_date_format("'%Y-%m-%d'") == "''yyyy-MM-dd''" + assert strftime_to_simple_date_format("%%Y") == "%'Y'" + + +def test_convert_python_to_java_datetime_format_with_single_quote() -> None: + assert strftime_to_simple_date_format("%Y'%m'%d") == "yyyy''MM''dd" From a530f3b0fbb208489a4c13d64de17f535b4c24f5 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 30 Sep 2024 19:01:18 +1000 Subject: [PATCH 23/43] chore: silence a few mypy complaints Strictly speaking, the typing annotation would allow for arrays to be used as keys in dictionaries, but as these are 'merely' typing hints, this should not matter much and should rarely lead to confusion. Instead, this will simplify some typing in cases dealing with arrays and mappings. Signed-off-by: JP-Ellis --- src/pact/v3/match/types.pyi | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pact/v3/match/types.pyi b/src/pact/v3/match/types.pyi index 6bf8b7745..7bcbc5d53 100644 --- a/src/pact/v3/match/types.pyi +++ b/src/pact/v3/match/types.pyi @@ -23,7 +23,7 @@ See: https://docs.python.org/3/library/stdtypes.html _ContainerMatchable: TypeAlias = ( Sequence[Matchable] | AbstractSet[Matchable] - | Mapping[_BaseMatchable, Matchable] + | Mapping[Matchable, Matchable] | Collection[Matchable] ) """ @@ -65,7 +65,7 @@ _MatchableT = TypeVar( # ContainerMatchable Sequence[Matchable], AbstractSet[Matchable], - Mapping[_BaseMatchable, Matchable], + Mapping[Matchable, Matchable], Collection[Matchable], # StdlibMatchable Decimal, @@ -75,4 +75,7 @@ _MatchableT = TypeVar( datetime, # ExtraMatchable BaseModel, + # This last one silences a number of mypy complaints if trying to have a + # generic `Matcher[Matchable]` type. + Matchable, ) From fa3247ffe572fa06a4b63dd954dffd9caa79aca7 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 30 Sep 2024 19:04:23 +1000 Subject: [PATCH 24/43] feat: improve match module A massive thanks to Val from all of the groundwork! I'm playing around here with shadowing Python built-ins to provide a more Pythonic interface. E.g., `match.int` and `match.str`, etc. I've also adjusted a number of arguments to allow shadowing built-ins (e.g., min, max) as the limited context poses little risk of confusion. Lastly, restructed a number of the argument to ensure that the value is _always_ a position argument, and in most cases, the remaining arguments are _always_ key word arguments. This will prevent `match.int(1, 2, 3)` where it may not be immediately obvious what each argument corresponds to. Signed-off-by: JP-Ellis --- src/pact/v3/match/__init__.py | 710 +++++++++++++++++++++++++--------- 1 file changed, 524 insertions(+), 186 deletions(-) diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index 3b570110f..54e833294 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -39,294 +39,627 @@ 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 -from pact.v3.match.types import Matcher - -if TYPE_CHECKING: - from pact.v3.match.types import MatchType +import builtins +import datetime as dt +import warnings +from decimal import Decimal +from typing import TYPE_CHECKING, Mapping, Sequence, TypeVar, overload +from pact.v3 import generate +from pact.v3.match.matcher import GenericMatcher, Matcher, Unset, _Unset +from pact.v3.util import strftime_to_simple_date_format -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. +if TYPE_CHECKING: + from types import ModuleType + + from pact.v3.generate import Generator + from pact.v3.match.types import Matchable, _MatchableT + +# ruff: noqa: A001 +# We provide a more 'Pythonic' interface by matching the names of the +# functions to the types they match (e.g., `match.int` matches integers). +# This overrides the built-in types which are accessed via the `builtins` +# module. +# ruff: noqa: A002 +# We only for overrides of built-ins like `min`, `max` and `type` as +# arguments to provide a nicer interface for the user. + +# The Pact specification allows for arbitrary matching rules to be defined; +# however in practice, only the matchers provided by the FFI are used and +# supported. +# +# +__all__ = [ + "Matcher", + "int", + "decimal", + "float", + "number", + "str", + "regex", + "bool", + "date", + "time", + "timestamp", + "datetime", + "null", + "type", + "like", + "each_like", + "includes", + "array_containing", + "each_key_matches", + "each_value_matches", +] + + +# We prevent users from importing from this module to avoid shadowing built-ins. +__builtins_import = builtins.__import__ + + +def __import__( # noqa: N807 + name: builtins.str, + globals: Mapping[builtins.str, object] | None = None, + locals: Mapping[builtins.str, object] | None = None, + fromlist: Sequence[builtins.str] = (), + level: builtins.int = 0, +) -> ModuleType: + """ + Override to warn when importing functions directly from this module. + + This function is used to override the built-in `__import__` function to + warn users when they import functions directly from this module. This is + done to avoid shadowing built-in types and functions. + """ + if name == "pact.v3.match" and len(set(fromlist) - {"Matcher"}) > 0: + warnings.warn( + "Avoid `from pact.v3.match import `. " + "Prefer importing `match` and use `match.`", + stacklevel=2, + ) + return __builtins_import(name, globals, locals, fromlist, level) + + +builtins.__import__ = __import__ + + +def int( + value: builtins.int | Unset = _Unset, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> Matcher[builtins.int]: + """ + Match 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. + Default value to use when generating a consumer test. + min: + If provided, the minimum value of the integer to generate. + max: + If provided, the maximum value of the integer to generate. """ - return ConcreteMatcher( + return GenericMatcher( "integer", - value, - generator=random_int(min_val, max_val), + value=value, + generator=generate.random_int(min, max), ) -def decimal(value: float | None = None, digits: int | None = None) -> Matcher: +_NumberT = TypeVar("_NumberT", builtins.int, builtins.float, Decimal) + + +def float( + value: _NumberT | Unset = _Unset, + /, + *, + precision: builtins.int | None = None, +) -> Matcher[_NumberT]: """ - Returns a matcher that matches a decimal value. + Match a floating point number. 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. + Default value to use when generating a consumer test. + precision: + The number of decimal precision to generate. """ - return ConcreteMatcher("decimal", value, generator=random_decimal(digits)) + return GenericMatcher( + "decimal", + value, + generator=generate.random_decimal(precision), + ) -def number( - value: float | None = None, - min_val: int | None = None, - max_val: int | None = None, - digits: int | None = None, -) -> Matcher: +def decimal( + value: _NumberT | Unset = _Unset, + /, + *, + precision: builtins.int | None = None, +) -> Matcher[_NumberT]: """ - Returns a matcher that matches a number value. + Alias for [`float`][pact.v3.match.float]. + """ + return float(value, precision=precision) - 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) +@overload +def number( + value: builtins.int, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> Matcher[builtins.int]: ... +@overload +def number( + value: builtins.float, + /, + *, + precision: builtins.int | None = None, +) -> Matcher[builtins.float]: ... +@overload +def number( + value: Decimal, + /, + *, + precision: builtins.int | None = None, +) -> Matcher[Decimal]: ... +@overload +def number( + value: Unset = _Unset, + /, +) -> Matcher[builtins.float]: ... +def number( + value: builtins.int | builtins.float | Decimal | Unset = _Unset, # noqa: PYI041 + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, + precision: builtins.int | None = None, +) -> Matcher[builtins.int] | Matcher[builtins.float] | Matcher[Decimal]: + """ + Match a general number. - 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) + This matcher is a generalization of the [`integer`][pact.v3.match.integer] + and [`decimal`][pact.v3.match.decimal] matchers. It can be used to match any + number, whether it is an integer or a float. + Th -def string( - value: str | None = None, - size: int | None = None, + Args: + value: + Default value to use when generating a consumer test. + min: + The minimum value of the number to generate. Only used when value is + an integer. Defaults to None. + max: + The maximum value of the number to generate. Only used when value is + an integer. Defaults to None. + precision: + The number of decimal digits to generate. Only used when value is a + float. Defaults to None. + """ + if isinstance(value, builtins.int): + if precision is not None: + warnings.warn( + "The precision argument is ignored when value is an integer.", + stacklevel=2, + ) + return GenericMatcher( + "number", + value=value, + generator=generate.random_int(min, max), + ) + + if isinstance(value, builtins.float): + if min is not None or max is not None: + warnings.warn( + "The min and max arguments are ignored when value is not an integer.", + stacklevel=2, + ) + return GenericMatcher( + "number", + value=value, + generator=generate.random_decimal(precision), + ) + + if isinstance(value, Decimal): + if min is not None or max is not None: + warnings.warn( + "The min and max arguments are ignored when value is not an integer.", + stacklevel=2, + ) + return GenericMatcher( + "number", + value=value, + generator=generate.random_decimal(precision), + ) + + msg = f"Unsupported number type: {builtins.type(value)}" + raise TypeError(msg) + + +def str( + value: builtins.str | Unset = _Unset, + /, + *, + size: builtins.int | None = None, generator: Generator | None = None, -) -> Matcher: +) -> Matcher[builtins.str]: """ - Returns a matcher that matches a string value. + Match a string value. + + This function can be used to match a string value, merely verifying that the + value is a string, possibly with a specific length. Args: value: - The value to return when running a consumer test. Defaults to None. + Default value to use when generating a consumer test. size: - The size of the string to generate. Defaults to None. + If no generator is provided, the size of the string to generate + during a consumer test. 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)) + Alternative generator to use when generating a consumer test. + """ + if size and generator: + warnings.warn( + "The size argument is ignored when a generator is provided.", + stacklevel=2, + ) + return GenericMatcher( + "type", + value=value, + generator=generator or generate.random_string(size), + ) -def boolean(*, value: bool | None = True) -> Matcher: +def regex( + value: builtins.str | Unset = _Unset, + /, + *, + regex: builtins.str | None = None, +) -> Matcher[builtins.str]: """ - Returns a matcher that matches a boolean value. + Match a string against a regular expression. Args: value: - The value to return when running a consumer test. Defaults to True. + Default value to use when generating a consumer test. + regex: + The regular expression to match against. """ - return ConcreteMatcher("boolean", value, generator=random_boolean()) + if regex is None: + msg = "A regex pattern must be provided." + raise ValueError(msg) + return GenericMatcher( + "regex", + value, + generator=generate.regex(regex), + regex=regex, + ) -def date(format_str: str, value: str | None = None) -> Matcher: +def bool(value: builtins.bool | Unset = _Unset, /) -> Matcher[builtins.bool]: """ - Returns a matcher that matches a date value. + Match a boolean 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. + Default value to use when generating a consumer test. """ - return ConcreteMatcher( - "date", value, format=format_str, generator=date_generator(format_str) - ) + return GenericMatcher("boolean", value, generator=generate.random_boolean()) -def time(format_str: str, value: str | None = None) -> Matcher: +def date( + value: dt.date | builtins.str | Unset = _Unset, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> Matcher[builtins.str]: """ - Returns a matcher that matches a time value. + Match a date value. + + A date value is a string that represents a date in a specific format. It + does _not_ have any time information. 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) + Default value to use when generating a consumer test. + format: + Expected format of the date. This uses Python's [`strftime` + format](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) + + Pact internally uses the [Java + SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + and the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format is done in + [`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format]. + + If not provided, an ISO 8601 date format will be used. + disable_conversion: + If True, the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format will be disabled, and the format must be + in Java's `SimpleDateFormat` format. As a result, the value must + be a string as Python cannot format the date in the target format. + """ + if disable_conversion: + if not isinstance(value, builtins.str): + msg = "When disable_conversion is True, the value must be a string." + raise ValueError(msg) + return GenericMatcher( + "date", + value=value, + format=format, + generator=generate.date(format or "yyyy-MM-dd", disable_conversion=True), + ) + + format = format or "%Y-%m-%d" + if isinstance(value, dt.date): + value = value.strftime(format) + format = strftime_to_simple_date_format(format) + return GenericMatcher( + "date", + value=value, + format=format, + generator=generate.date(format, disable_conversion=True), ) -def timestamp(format_str: str, value: str | None = None) -> Matcher: +def time( + value: dt.time | builtins.str | Unset = _Unset, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> Matcher[builtins.str]: """ - Returns a matcher that matches a timestamp value. + Match a time value. + + A time value is a string that represents a time in a specific format. It + does _not_ have any date information. 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. + Default value to use when generating a consumer test. + format: + Expected format of the time. This uses Python's [`strftime` + format](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) + + Pact internally uses the [Java + SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + and the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format is done in + [`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format]. + + If not provided, an ISO 8601 time format will be used. + disable_conversion: + If True, the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format will be disabled, and the format must be + in Java's `SimpleDateFormat` format. As a result, the value must + be a string as Python cannot format the time in the target format. + """ + if disable_conversion: + if not isinstance(value, builtins.str): + msg = "When disable_conversion is True, the value must be a string." + raise ValueError(msg) + return GenericMatcher( + "time", + value=value, + format=format, + generator=generate.time(format or "HH:mm:ss", disable_conversion=True), + ) + format = format or "%H:%M:%S" + if isinstance(value, dt.time): + value = value.strftime(format) + format = strftime_to_simple_date_format(format) + return GenericMatcher( + "time", + value=value, + format=format, + generator=generate.time(format, disable_conversion=True), + ) + + +def timestamp( + value: dt.datetime | builtins.str | Unset = _Unset, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> Matcher[builtins.str]: """ - return ConcreteMatcher( + Match a timestamp value. + + A timestamp value is a string that represents a date and time in a specific + format. + + Args: + value: + Default value to use when generating a consumer test. + format: + Expected format of the timestamp. This uses Python's [`strftime` + format](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) + + Pact internally uses the [Java + SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + and the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format is done in + [`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format]. + + If not provided, an ISO 8601 timestamp format will be used. + disable_conversion: + If True, the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format will be disabled, and the format must be + in Java's `SimpleDateFormat` format. As a result, the value must + be a string as Python cannot format the timestamp in the target format. + """ + if disable_conversion: + if not isinstance(value, builtins.str): + msg = "When disable_conversion is True, the value must be a string." + raise ValueError(msg) + return GenericMatcher( + "timestamp", + value=value, + format=format, + generator=generate.date_time( + format or "yyyy-MM-dd'T'HH:mm:ss", disable_conversion=True + ), + ) + format = format or "%Y-%m-%d'T'%H:%M:%S" + if isinstance(value, dt.datetime): + value = value.strftime(format) + format = strftime_to_simple_date_format(format) + return GenericMatcher( "timestamp", - value, - format=format_str, - generator=date_time(format_str), + value=value, + format=format, + generator=generate.date_time(format, disable_conversion=True), ) -def null() -> Matcher: +def datetime( + value: dt.datetime | builtins.str | Unset = _Unset, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> Matcher[builtins.str]: """ - Returns a matcher that matches a null value. + Alias for [`timestamp`][pact.v3.match.timestamp]. """ - return ConcreteMatcher("null") + return timestamp(value, format, disable_conversion=disable_conversion) -def like( - value: MatchType, - min_count: int | None = None, - max_count: int | None = None, +def none() -> Matcher[None]: + """ + Match a null value. + """ + return GenericMatcher("null") + + +def null() -> Matcher[None]: + """ + Alias for [`none`][pact.v3.match.none]. + """ + return none() + + +def type( + value: _MatchableT, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, generator: Generator | None = None, -) -> Matcher: +) -> Matcher[_MatchableT]: """ - Returns a matcher that matches the given template. + Match a value by type. 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. + A value to match against. This can be a primitive value, or a more + complex object or array. + min: + The minimum number of items that must match the value. + max: + The maximum number of items that must match the value. generator: - The generator to use when generating the value. Defaults to None. + The generator to use when generating the value. """ - return ConcreteMatcher( - "type", value, min=min_count, max=max_count, generator=generator - ) + return GenericMatcher("type", value, min=min, max=max, generator=generator) + + +def like( + value: _MatchableT, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, + generator: Generator | None = None, +) -> Matcher[_MatchableT]: + """ + Alias for [`type`][pact.v3.match.type]. + """ + return type(value, min=min, max=max, generator=generator) def each_like( - value: MatchType, - min_count: int | None = 1, - max_count: int | None = None, -) -> Matcher: + value: _MatchableT, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> Matcher[Sequence[_MatchableT]]: """ - Returns a matcher that matches each item in an array against a given value. + Match 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. + The value itself is arbitrary, and can include other matchers. Args: value: The value to match against. - min_count: - The minimum number of items that must match the value. Default is 1. - max_count: + min: + The minimum number of items that must match the value. The minimum + value is always 1, even if min is set to 0. + max: The maximum number of items that must match the value. """ - return ConcreteMatcher("type", [value], min=min_count, max=max_count) + if min is not None and min < 1: + warnings.warn( + "The minimum number of items must be at least 1.", + stacklevel=2, + ) + return GenericMatcher("type", value=[value], min=min, max=max) -def includes(value: str, generator: Generator | None = None) -> Matcher: +def includes( + value: builtins.str, + /, + *, + generator: Generator | None = None, +) -> Matcher[builtins.str]: """ - Returns a matcher that matches a string that includes the given value. + Match a string that includes a given value. Args: value: The value to match against. generator: - The generator to use when generating the value. Defaults to None. + The generator to use when generating the value. """ - return ConcreteMatcher("include", value, generator=generator, force_generator=True) + return GenericMatcher( + "include", + value=value, + generator=generator, + ) -def array_containing(variants: list[MatchType]) -> Matcher: +def array_containing(variants: list[Matchable], /) -> Matcher[Matchable]: """ - Returns a matcher that matches the items in an array against a number of variants. + Match an array that contains the given variants. - Matching is successful if each variant occurs once in the array. Variants may be - objects containing matching rules. + 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, - ) + return GenericMatcher("arrayContains", variants=variants) -def each_key_matches(value: MatchType, rules: Matcher | list[Matcher]) -> Matcher: +def each_key_matches( + value: _MatchableT, + /, + *, + rules: Matcher[Matchable] | list[Matcher[Matchable]], +) -> Matcher[Mapping[_MatchableT, Matchable]]: """ - Returns a matcher that matches each key in a dictionary against a set of rules. + Match each key in a dictionary against a set of rules. Args: value: @@ -336,10 +669,15 @@ def each_key_matches(value: MatchType, rules: Matcher | list[Matcher]) -> Matche """ if isinstance(rules, Matcher): rules = [rules] - return ConcreteMatcher("eachKey", value, rules=rules) + return GenericMatcher("eachKey", value=value, rules=rules) -def each_value_matches(value: MatchType, rules: Matcher | list[Matcher]) -> Matcher: +def each_value_matches( + value: _MatchableT, + /, + *, + rules: Matcher[Matchable] | list[Matcher[Matchable]], +) -> Matcher[Mapping[Matchable, _MatchableT]]: """ Returns a matcher that matches each value in a dictionary against a set of rules. @@ -351,4 +689,4 @@ def each_value_matches(value: MatchType, rules: Matcher | list[Matcher]) -> Matc """ if isinstance(rules, Matcher): rules = [rules] - return ConcreteMatcher("eachValue", value, rules=rules) + return GenericMatcher("eachValue", value=value, rules=rules) From 77eeeabe2896f7a943ed17405cf53b14c1f9ee03 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 30 Sep 2024 19:07:07 +1000 Subject: [PATCH 25/43] chore: add pyi to editor config Signed-off-by: JP-Ellis --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index acd41cef1..e0c8b5cf0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,7 +11,7 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true -[*.py] +[*.{py,pyi}] indent_size = 4 [Makefile] From dc5acc29c5b5b2d9e3acb70e93f717593696d734 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 10:49:41 +1000 Subject: [PATCH 26/43] chore: add test for full ISO 8601 date Signed-off-by: JP-Ellis --- tests/v3/test_util.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/v3/test_util.py b/tests/v3/test_util.py index 1ef039397..65f915f83 100644 --- a/tests/v3/test_util.py +++ b/tests/v3/test_util.py @@ -10,6 +10,9 @@ def test_convert_python_to_java_datetime_format_basic() -> None: assert strftime_to_simple_date_format("%Y-%m-%d") == "yyyy-MM-dd" assert strftime_to_simple_date_format("%H:%M:%S") == "HH:mm:ss" + assert ( + strftime_to_simple_date_format("%Y-%m-%dT%H:%M:%S") == "yyyy-MM-dd'T'HH:mm:ss" + ) def test_convert_python_to_java_datetime_format_with_unsupported_code() -> None: From 962f64219d0325aec4b9236a0dd0e52349097f0b Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 10:58:49 +1000 Subject: [PATCH 27/43] feat: add match aliases The Pact ecosystem has a number of commonly used names for matchers. Aliases are added to ensure compatibility with what one might expect from using another Pact implementation. Signed-off-by: JP-Ellis --- src/pact/v3/match/__init__.py | 58 ++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index 54e833294..727e85abb 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -80,8 +80,8 @@ "bool", "date", "time", - "timestamp", "datetime", + "timestamp", "null", "type", "like", @@ -148,6 +148,19 @@ def int( ) +def integer( + value: builtins.int | Unset = _Unset, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> Matcher[builtins.int]: + """ + Alias for [`match.int`][pact.v3.match.int]. + """ + return int(value, min=min, max=max) + + _NumberT = TypeVar("_NumberT", builtins.int, builtins.float, Decimal) @@ -180,7 +193,7 @@ def decimal( precision: builtins.int | None = None, ) -> Matcher[_NumberT]: """ - Alias for [`float`][pact.v3.match.float]. + Alias for [`match.float`][pact.v3.match.float]. """ return float(value, precision=precision) @@ -316,6 +329,19 @@ def str( ) +def string( + value: builtins.str | Unset = _Unset, + /, + *, + size: builtins.int | None = None, + generator: Generator | None = None, +) -> Matcher[builtins.str]: + """ + Alias for [`match.str`][pact.v3.match.str]. + """ + return str(value, size=size, generator=generator) + + def regex( value: builtins.str | Unset = _Unset, /, @@ -379,7 +405,7 @@ def date( `SimpleDateFormat` format is done in [`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format]. - If not provided, an ISO 8601 date format will be used. + If not provided, an ISO 8601 date format will be used: `%Y-%m-%d`. disable_conversion: If True, the conversion from Python's `strftime` format to Java's `SimpleDateFormat` format will be disabled, and the format must be @@ -435,7 +461,7 @@ def time( `SimpleDateFormat` format is done in [`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format]. - If not provided, an ISO 8601 time format will be used. + If not provided, an ISO 8601 time format will be used: `%H:%M:%S`. disable_conversion: If True, the conversion from Python's `strftime` format to Java's `SimpleDateFormat` format will be disabled, and the format must be @@ -464,7 +490,7 @@ def time( ) -def timestamp( +def datetime( value: dt.datetime | builtins.str | Unset = _Unset, /, format: builtins.str | None = None, @@ -472,7 +498,7 @@ def timestamp( disable_conversion: builtins.bool = False, ) -> Matcher[builtins.str]: """ - Match a timestamp value. + Match a datetime value. A timestamp value is a string that represents a date and time in a specific format. @@ -490,12 +516,13 @@ def timestamp( `SimpleDateFormat` format is done in [`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format]. - If not provided, an ISO 8601 timestamp format will be used. + If not provided, an ISO 8601 timestamp format will be used: + `%Y-%m-%dT%H:%M:%S`. disable_conversion: If True, the conversion from Python's `strftime` format to Java's `SimpleDateFormat` format will be disabled, and the format must be - in Java's `SimpleDateFormat` format. As a result, the value must - be a string as Python cannot format the timestamp in the target format. + in Java's `SimpleDateFormat` format. As a result, the value must be + a string as Python cannot format the timestamp in the target format. """ if disable_conversion: if not isinstance(value, builtins.str): @@ -506,10 +533,11 @@ def timestamp( value=value, format=format, generator=generate.date_time( - format or "yyyy-MM-dd'T'HH:mm:ss", disable_conversion=True + format or "yyyy-MM-dd'T'HH:mm:ss", + disable_conversion=True, ), ) - format = format or "%Y-%m-%d'T'%H:%M:%S" + format = format or "%Y-%m-%dT%H:%M:%S" if isinstance(value, dt.datetime): value = value.strftime(format) format = strftime_to_simple_date_format(format) @@ -521,7 +549,7 @@ def timestamp( ) -def datetime( +def timestamp( value: dt.datetime | builtins.str | Unset = _Unset, /, format: builtins.str | None = None, @@ -529,7 +557,7 @@ def datetime( disable_conversion: builtins.bool = False, ) -> Matcher[builtins.str]: """ - Alias for [`timestamp`][pact.v3.match.timestamp]. + Alias for [`match.datetime`][pact.v3.match.datetime]. """ return timestamp(value, format, disable_conversion=disable_conversion) @@ -543,7 +571,7 @@ def none() -> Matcher[None]: def null() -> Matcher[None]: """ - Alias for [`none`][pact.v3.match.none]. + Alias for [`match.none`][pact.v3.match.none]. """ return none() @@ -582,7 +610,7 @@ def like( generator: Generator | None = None, ) -> Matcher[_MatchableT]: """ - Alias for [`type`][pact.v3.match.type]. + Alias for [`match.type`][pact.v3.match.type]. """ return type(value, min=min, max=max, generator=generator) From 0856c4ef9d028eb6510688c8a19bd64152d22527 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 11:00:32 +1000 Subject: [PATCH 28/43] feat: add uuid matcher Signed-off-by: JP-Ellis --- src/pact/v3/match/__init__.py | 56 ++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index 727e85abb..56b4bc353 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -43,7 +43,7 @@ import datetime as dt import warnings from decimal import Decimal -from typing import TYPE_CHECKING, Mapping, Sequence, TypeVar, overload +from typing import TYPE_CHECKING, Literal, Mapping, Sequence, TypeVar, overload from pact.v3 import generate from pact.v3.match.matcher import GenericMatcher, Matcher, Unset, _Unset @@ -368,6 +368,60 @@ def regex( ) +_UUID_FORMATS = { + "simple": r"[0-9a-fA-F]{32}", + "lowercase": r"[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}", + "uppercase": r"[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}", + "urn": r"urn:uuid:[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}", +} + + +def uuid( + value: builtins.str | Unset = _Unset, + /, + *, + format: Literal["uppercase", "lowercase", "urn", "simple"] | None = None, +) -> Matcher[builtins.str]: + """ + Match a UUID value. + + This matcher internally combines the [`regex`][pact.v3.match.regex] matcher + with a UUID regex pattern. See [RFC + 4122](https://datatracker.ietf.org/doc/html/rfc4122) for details about the + UUID format. + + While RFC 4122 requires UUIDs to be output as lowercase, UUIDs are case + insensitive on input. Some common alternative formats can be enforced using + the `format` parameter. + + Args: + value: + Default value to use when generating a consumer test. + format: + Enforce a specific UUID format. The following formats are supported: + + - `simple`: 32 hexadecimal digits with no hyphens. This is _not_ a + valid UUID format, but is provided for convenience. + - `lowercase`: Lowercase hexadecimal digits with hyphens. + - `uppercase`: Uppercase hexadecimal digits with hyphens. + - `urn`: Lowercase hexadecimal digits with hyphens and a + `urn:uuid:` + + If not provided, the matcher will accept any lowercase or uppercase. + """ + pattern = ( + rf"^{_UUID_FORMATS[format]}$" + if format + else rf"^({_UUID_FORMATS['lowercase']}|{_UUID_FORMATS['uppercase']})$" + ) + return GenericMatcher( + "regex", + value=value, + regex=pattern, + generator=generate.uuid(format), + ) + + def bool(value: builtins.bool | Unset = _Unset, /) -> Matcher[builtins.bool]: """ Match a boolean value. From 2de9b8f576c086a5a8342786dad3677eb7567632 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 11:09:05 +1000 Subject: [PATCH 29/43] refactor: generate module in style of match module Signed-off-by: JP-Ellis --- src/pact/v3/generate/__init__.py | 183 ++++++++++++++++++++--- src/pact/v3/generate/generator.py | 81 ++++++++++ src/pact/v3/generate/generators.py | 232 ----------------------------- 3 files changed, 245 insertions(+), 251 deletions(-) create mode 100644 src/pact/v3/generate/generator.py delete mode 100644 src/pact/v3/generate/generators.py diff --git a/src/pact/v3/generate/__init__.py b/src/pact/v3/generate/__init__.py index ebe17fc93..2b81ec34f 100644 --- a/src/pact/v3/generate/__init__.py +++ b/src/pact/v3/generate/__init__.py @@ -2,30 +2,15 @@ Generator module. """ -from pact.v3.generate.generators import ( +from typing import Literal, Optional + +from pact.v3.generate.generator import ( + ConcreteGenerator, 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", - "GeneratorTypes", - "GeneratorTypeV3", - "GeneratorTypeV4", "random_int", "random_decimal", "random_hexadecimal", @@ -39,3 +24,163 @@ "provider_state", "mock_server_url", ] + + +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: + 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/generate/generator.py b/src/pact/v3/generate/generator.py new file mode 100644 index 000000000..33ce9c6f2 --- /dev/null +++ b/src/pact/v3/generate/generator.py @@ -0,0 +1,81 @@ +""" +Implementations of generators for the V3 and V4 specifications. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Literal, Optional, Union + +_GeneratorTypeV3 = Literal[ + "RandomInt", + "RandomDecimal", + "RandomHexadecimal", + "RandomString", + "Regex", + "Uuid", + "Date", + "Time", + "DateTime", + "RandomBoolean", +] +""" +Generators defines in the V3 specification. +""" + +_GeneratorTypeV4 = Literal["ProviderState", "MockServerURL"] +""" +Generators defined in the V4 specification. +""" + +GeneratorType = Union[_GeneratorTypeV3, _GeneratorTypeV4] +""" +All supported generator types. +""" + + +class Generator(ABC): + """ + 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: GeneratorType, + 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 diff --git a/src/pact/v3/generate/generators.py b/src/pact/v3/generate/generators.py deleted file mode 100644 index ebb847c01..000000000 --- a/src/pact/v3/generate/generators.py +++ /dev/null @@ -1,232 +0,0 @@ -""" -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: - 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 f0307b9aaf1bb74bbf35c75aae206d663a2ca4db Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 12:02:52 +1000 Subject: [PATCH 30/43] refactor: create pact.v3.types module Instead of having multiple types sub-modules for the generators and matchers, create a single shared `pact.v3.types` module. Signed-off-by: JP-Ellis --- src/pact/v3/generate/generator.py | 29 ++--------- src/pact/v3/match/__init__.py | 54 ++++++++++---------- src/pact/v3/match/matcher.py | 63 ++--------------------- src/pact/v3/match/types.py | 19 ------- src/pact/v3/types.py | 49 ++++++++++++++++++ src/pact/v3/{match => }/types.pyi | 83 ++++++++++++++++++++++++++++--- 6 files changed, 160 insertions(+), 137 deletions(-) delete mode 100644 src/pact/v3/match/types.py create mode 100644 src/pact/v3/types.py rename src/pact/v3/{match => }/types.pyi (54%) diff --git a/src/pact/v3/generate/generator.py b/src/pact/v3/generate/generator.py index 33ce9c6f2..96bafb128 100644 --- a/src/pact/v3/generate/generator.py +++ b/src/pact/v3/generate/generator.py @@ -5,33 +5,10 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Optional -_GeneratorTypeV3 = Literal[ - "RandomInt", - "RandomDecimal", - "RandomHexadecimal", - "RandomString", - "Regex", - "Uuid", - "Date", - "Time", - "DateTime", - "RandomBoolean", -] -""" -Generators defines in the V3 specification. -""" - -_GeneratorTypeV4 = Literal["ProviderState", "MockServerURL"] -""" -Generators defined in the V4 specification. -""" - -GeneratorType = Union[_GeneratorTypeV3, _GeneratorTypeV4] -""" -All supported generator types. -""" +if TYPE_CHECKING: + from pact.v3.types import GeneratorType class Generator(ABC): diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index 56b4bc353..e2090054d 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -46,14 +46,14 @@ from typing import TYPE_CHECKING, Literal, Mapping, Sequence, TypeVar, overload from pact.v3 import generate -from pact.v3.match.matcher import GenericMatcher, Matcher, Unset, _Unset +from pact.v3.match.matcher import GenericMatcher, Matcher +from pact.v3.types import UNSET, Matchable, MatchableT, Unset from pact.v3.util import strftime_to_simple_date_format if TYPE_CHECKING: from types import ModuleType from pact.v3.generate import Generator - from pact.v3.match.types import Matchable, _MatchableT # ruff: noqa: A001 # We provide a more 'Pythonic' interface by matching the names of the @@ -124,7 +124,7 @@ def __import__( # noqa: N807 def int( - value: builtins.int | Unset = _Unset, + value: builtins.int | Unset = UNSET, /, *, min: builtins.int | None = None, @@ -149,7 +149,7 @@ def int( def integer( - value: builtins.int | Unset = _Unset, + value: builtins.int | Unset = UNSET, /, *, min: builtins.int | None = None, @@ -165,7 +165,7 @@ def integer( def float( - value: _NumberT | Unset = _Unset, + value: _NumberT | Unset = UNSET, /, *, precision: builtins.int | None = None, @@ -187,7 +187,7 @@ def float( def decimal( - value: _NumberT | Unset = _Unset, + value: _NumberT | Unset = UNSET, /, *, precision: builtins.int | None = None, @@ -222,11 +222,11 @@ def number( ) -> Matcher[Decimal]: ... @overload def number( - value: Unset = _Unset, + value: Unset = UNSET, /, ) -> Matcher[builtins.float]: ... def number( - value: builtins.int | builtins.float | Decimal | Unset = _Unset, # noqa: PYI041 + value: builtins.int | builtins.float | Decimal | Unset = UNSET, # noqa: PYI041 /, *, min: builtins.int | None = None, @@ -296,7 +296,7 @@ def number( def str( - value: builtins.str | Unset = _Unset, + value: builtins.str | Unset = UNSET, /, *, size: builtins.int | None = None, @@ -330,7 +330,7 @@ def str( def string( - value: builtins.str | Unset = _Unset, + value: builtins.str | Unset = UNSET, /, *, size: builtins.int | None = None, @@ -343,7 +343,7 @@ def string( def regex( - value: builtins.str | Unset = _Unset, + value: builtins.str | Unset = UNSET, /, *, regex: builtins.str | None = None, @@ -377,7 +377,7 @@ def regex( def uuid( - value: builtins.str | Unset = _Unset, + value: builtins.str | Unset = UNSET, /, *, format: Literal["uppercase", "lowercase", "urn", "simple"] | None = None, @@ -422,7 +422,7 @@ def uuid( ) -def bool(value: builtins.bool | Unset = _Unset, /) -> Matcher[builtins.bool]: +def bool(value: builtins.bool | Unset = UNSET, /) -> Matcher[builtins.bool]: """ Match a boolean value. @@ -434,7 +434,7 @@ def bool(value: builtins.bool | Unset = _Unset, /) -> Matcher[builtins.bool]: def date( - value: dt.date | builtins.str | Unset = _Unset, + value: dt.date | builtins.str | Unset = UNSET, /, format: builtins.str | None = None, *, @@ -490,7 +490,7 @@ def date( def time( - value: dt.time | builtins.str | Unset = _Unset, + value: dt.time | builtins.str | Unset = UNSET, /, format: builtins.str | None = None, *, @@ -545,7 +545,7 @@ def time( def datetime( - value: dt.datetime | builtins.str | Unset = _Unset, + value: dt.datetime | builtins.str | Unset = UNSET, /, format: builtins.str | None = None, *, @@ -604,7 +604,7 @@ def datetime( def timestamp( - value: dt.datetime | builtins.str | Unset = _Unset, + value: dt.datetime | builtins.str | Unset = UNSET, /, format: builtins.str | None = None, *, @@ -631,13 +631,13 @@ def null() -> Matcher[None]: def type( - value: _MatchableT, + value: MatchableT, /, *, min: builtins.int | None = None, max: builtins.int | None = None, generator: Generator | None = None, -) -> Matcher[_MatchableT]: +) -> Matcher[MatchableT]: """ Match a value by type. @@ -656,13 +656,13 @@ def type( def like( - value: _MatchableT, + value: MatchableT, /, *, min: builtins.int | None = None, max: builtins.int | None = None, generator: Generator | None = None, -) -> Matcher[_MatchableT]: +) -> Matcher[MatchableT]: """ Alias for [`match.type`][pact.v3.match.type]. """ @@ -670,12 +670,12 @@ def like( def each_like( - value: _MatchableT, + value: MatchableT, /, *, min: builtins.int | None = None, max: builtins.int | None = None, -) -> Matcher[Sequence[_MatchableT]]: +) -> Matcher[Sequence[MatchableT]]: """ Match each item in an array against a given value. @@ -735,11 +735,11 @@ def array_containing(variants: list[Matchable], /) -> Matcher[Matchable]: def each_key_matches( - value: _MatchableT, + value: MatchableT, /, *, rules: Matcher[Matchable] | list[Matcher[Matchable]], -) -> Matcher[Mapping[_MatchableT, Matchable]]: +) -> Matcher[Mapping[MatchableT, Matchable]]: """ Match each key in a dictionary against a set of rules. @@ -755,11 +755,11 @@ def each_key_matches( def each_value_matches( - value: _MatchableT, + value: MatchableT, /, *, rules: Matcher[Matchable] | list[Matcher[Matchable]], -) -> Matcher[Mapping[Matchable, _MatchableT]]: +) -> Matcher[Mapping[Matchable, MatchableT]]: """ Returns a matcher that matches each value in a dictionary against a set of rules. diff --git a/src/pact/v3/match/matcher.py b/src/pact/v3/match/matcher.py index 439d21e58..3286984bb 100644 --- a/src/pact/v3/match/matcher.py +++ b/src/pact/v3/match/matcher.py @@ -16,70 +16,17 @@ TYPE_CHECKING, Any, Generic, - Literal, - Union, ) -from pact.v3.match.types import Matchable, _MatchableT +from pact.v3.types import UNSET, Matchable, MatchableT, MatcherType, Unset 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]): +class Matcher(ABC, Generic[MatchableT]): """ Abstract matcher. @@ -133,7 +80,7 @@ def to_matching_rule(self) -> dict[str, Any]: """ -class GenericMatcher(Matcher[_MatchableT]): +class GenericMatcher(Matcher[MatchableT]): """ Generic matcher. @@ -146,7 +93,7 @@ def __init__( # noqa: PLR0913 self, type: MatcherType, # noqa: A002 /, - value: _MatchableT | Unset = _Unset, + value: MatchableT | Unset = UNSET, generator: Generator | None = None, extra_fields: Mapping[str, Matchable] | None = None, integration_fields: Mapping[str, Matchable] | None = None, @@ -192,7 +139,7 @@ def __init__( # noqa: PLR0913 The type of the matcher. """ - self.value: _MatchableT | Unset = value + self.value: MatchableT | Unset = value """ Default value used by Pact when executing tests. """ diff --git a/src/pact/v3/match/types.py b/src/pact/v3/match/types.py deleted file mode 100644 index 90b06c9f1..000000000 --- a/src/pact/v3/match/types.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Typing definitions for the matchers. -""" - -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/types.py b/src/pact/v3/types.py new file mode 100644 index 000000000..26bcc484c --- /dev/null +++ b/src/pact/v3/types.py @@ -0,0 +1,49 @@ +""" +Typing definitions for the matchers. + +This module provides basic type definitions, and is the runtime counterpart to +the `types.pyi` stub file. The latter is used to provide much richer type +information to static type checkers like `mypy`. +""" + +from typing import Any, TypeAlias, TypeVar + +Matchable: TypeAlias = Any +""" +All supported matchable types. +""" + +MatchableT = TypeVar("MatchableT") +""" +Matchable type variable. +""" + +MatcherType: TypeAlias = str +""" +All supported matchers. +""" + +GeneratorType: TypeAlias = str +""" +All supported generator types. +""" + + +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() +""" +Instance of the `Unset` class. + +This is used to provide a default value for an optional argument that needs to +differentiate between a `None` value and an unset value. +""" diff --git a/src/pact/v3/match/types.pyi b/src/pact/v3/types.pyi similarity index 54% rename from src/pact/v3/match/types.pyi rename to src/pact/v3/types.pyi index 7bcbc5d53..981c9c632 100644 --- a/src/pact/v3/match/types.pyi +++ b/src/pact/v3/types.pyi @@ -1,16 +1,18 @@ +# Types stubs file +# +# This file is only used during type checking, and is ignored during runtime. +# As a result, it is safe to perform expensive imports, even if they are not +# used or available at runtime. + from collections.abc import Collection, Mapping, Sequence from collections.abc import Set as AbstractSet from datetime import date, datetime, time from decimal import Decimal from fractions import Fraction -from typing import TypeAlias, TypeVar +from typing import Literal, 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 ) @@ -50,8 +52,8 @@ Matchable: TypeAlias = ( All supported matchable types. """ -_MatchableT = TypeVar( - "_MatchableT", +MatchableT = TypeVar( # noqa: PYI001 + "MatchableT", # BaseMatchable int, float, @@ -79,3 +81,70 @@ _MatchableT = TypeVar( # generic `Matcher[Matchable]` type. Matchable, ) + +_MatcherTypeV3: TypeAlias = Literal[ + "equality", + "regex", + "type", + "include", + "integer", + "decimal", + "number", + "timestamp", + "time", + "date", + "null", + "boolean", + "contentType", + "values", + "arrayContains", +] +""" +Matchers defined in the V3 specification. +""" + +_MatcherTypeV4: TypeAlias = Literal[ + "statusCode", + "notEmpty", + "semver", + "eachKey", + "eachValue", +] +""" +Matchers defined in the V4 specification. +""" + +MatcherType: TypeAlias = _MatcherTypeV3 | _MatcherTypeV4 +""" +All supported matchers. +""" + +_GeneratorTypeV3: TypeAlias = Literal[ + "RandomInt", + "RandomDecimal", + "RandomHexadecimal", + "RandomString", + "Regex", + "Uuid", + "Date", + "Time", + "DateTime", + "RandomBoolean", +] +""" +Generators defines in the V3 specification. +""" + +_GeneratorTypeV4: TypeAlias = Literal["ProviderState", "MockServerURL"] +""" +Generators defined in the V4 specification. +""" + +GeneratorType: TypeAlias = _GeneratorTypeV3 | _GeneratorTypeV4 +""" +All supported generator types. +""" + +class Unset: ... + +UNSET = Unset() From 289fd5e02c729acaf2f76646691c1e76b90ac695 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 13:45:44 +1000 Subject: [PATCH 31/43] chore: minor improvements to match.matcher Signed-off-by: JP-Ellis --- src/pact/v3/match/matcher.py | 75 +++++++++--------------------------- 1 file changed, 18 insertions(+), 57 deletions(-) diff --git a/src/pact/v3/match/matcher.py b/src/pact/v3/match/matcher.py index 3286984bb..adbae6691 100644 --- a/src/pact/v3/match/matcher.py +++ b/src/pact/v3/match/matcher.py @@ -12,19 +12,14 @@ from abc import ABC, abstractmethod from itertools import chain from json import JSONEncoder -from typing import ( - TYPE_CHECKING, - Any, - Generic, -) +from typing import TYPE_CHECKING, Any, Generic +from pact.v3.generate.generator import Generator from pact.v3.types import UNSET, Matchable, MatchableT, MatcherType, Unset if TYPE_CHECKING: from collections.abc import Mapping - from pact.v3.generate import Generator - class Matcher(ABC, Generic[MatchableT]): """ @@ -40,7 +35,7 @@ class Matcher(ABC, Generic[MatchableT]): 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. + communicating with the FFI and generating the Pact file. """ @abstractmethod @@ -84,9 +79,8 @@ 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. + A generic matcher, with the ability to define arbitrary additional fields + for inclusion in the integration JSON object and matching rule. """ def __init__( # noqa: PLR0913 @@ -94,10 +88,10 @@ def __init__( # noqa: PLR0913 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, + generator: Generator[MatchableT] | None = None, + extra_fields: Mapping[str, Any] | None = None, + integration_fields: Mapping[str, Any] | None = None, + matching_rule_fields: Mapping[str, Any] | None = None, **kwargs: Matchable, ) -> None: """ @@ -119,8 +113,8 @@ def __init__( # noqa: PLR0913 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. + fields will be used when converting the matcher to both an + integration JSON object and a matching rule. integration_fields: Additional configuration elements to pass to the matcher when @@ -144,7 +138,7 @@ def __init__( # noqa: PLR0913 Default value used by Pact when executing tests. """ - self.generator = generator + self.generator: Generator[MatchableT] | None = generator """ Generator used to generate a value when the value is not provided. """ @@ -159,41 +153,6 @@ def has_value(self) -> bool: """ 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. @@ -217,7 +176,8 @@ def to_integration_json(self) -> dict[str, Matchable]: if self.generator is not None else {} ), - **self.extra_integration_fields(), + **self._extra_fields, + **self._integration_fields, } def to_matching_rule(self) -> dict[str, Any]: @@ -242,9 +202,8 @@ def to_matching_rule(self) -> dict[str, Any]: 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(), + **self._extra_fields, + **self._matching_rule_fields, } @@ -277,4 +236,6 @@ def default(self, o: Any) -> Any: # noqa: ANN401 """ if isinstance(o, Matcher): return o.to_integration_json() + if isinstance(o, Generator): + return o.to_integration_json() return super().default(o) From fabd01dcb0e59ecb28ac5d4e20014d6e17c3427b Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 13:46:21 +1000 Subject: [PATCH 32/43] chore: align generator with matcher The generator and matcher behave very similarly, and this brings the generator in line with the matcher declaration. Signed-off-by: JP-Ellis --- src/pact/v3/generate/generator.py | 141 ++++++++++++++++++++++++++---- 1 file changed, 122 insertions(+), 19 deletions(-) diff --git a/src/pact/v3/generate/generator.py b/src/pact/v3/generate/generator.py index 96bafb128..21d5c3434 100644 --- a/src/pact/v3/generate/generator.py +++ b/src/pact/v3/generate/generator.py @@ -5,7 +5,8 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Optional +from itertools import chain +from typing import TYPE_CHECKING, Any, Mapping if TYPE_CHECKING: from pact.v3.types import GeneratorType @@ -13,46 +14,148 @@ class Generator(ABC): """ - Generator interface for exporting. + Abstract generator. + + In Pact, a generator is used by Pact to generate data on-the-fly during the + contract verification process. Generators are used in combination with + matchers to provide more flexible matching of data. + + This class is abstract and should not be used directly. Instead, use one of + the concrete generator classes. Alternatively, you can create your own + generator 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 + communicating with the FFI and generating the Pact file. """ @abstractmethod - def to_dict(self) -> dict[str, Any]: + 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. """ - Convert the generator to a dictionary for json serialization. + + @abstractmethod + def to_generator_json(self) -> dict[str, Any]: """ + Convert the generator to a generator JSON object. + This method is used internally to convert the generator to a JSON object + which can be embedded directly in a number of places in the Pact FFI. -class ConcreteGenerator(Generator): + For more information about this format, see the docs: + + > https://github.com/pact-foundation/pact-specification/tree/version-4 + + and + + > https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers + + Returns: + The generator as a generator JSON object. + """ + + +class GenericGenerator(Generator): """ - ConcreteGenerator class. + Generic generator. - A generator is used to generate values for a field in a response. + A generic generator, with the ability to specify the generator type and + additional configuration elements. """ def __init__( self, - generator_type: GeneratorType, - extra_args: Optional[dict[str, Any]] = None, + type: GeneratorType, # noqa: A002 + /, + extra_fields: Mapping[str, Any] | None = None, + integration_fields: Mapping[str, Any] | None = None, + generator_json_fields: Mapping[str, Any] | None = None, + **kwargs: Any, # noqa: ANN401 ) -> None: """ Instantiate the generator class. Args: - generator_type (GeneratorTypeV4): - The type of generator to use. - extra_args (dict[str, Any], optional): + type: + The type of the generator. + + extra_fields: Additional configuration elements to pass to the generator. + These fields will be used when converting the generator to both an + integration JSON object and a generator JSON object. + + integration_fields: + Additional configuration elements to pass to the generator when + converting to an integration JSON object. + + generator_json_fields: + Additional configuration elements to pass to the generator when + converting to a generator JSON object. + + **kwargs: + Alternative way to pass additional configuration elements to the + generator. See the `extra_fields` argument for more information. + """ + self.type = type """ - self.type = generator_type - self.extra_args = extra_args if extra_args is not None else {} + The type of the generator. + """ + + self._integration_fields = integration_fields or {} + self._generator_json_fields = generator_json_fields or {} + self._extra_fields = dict(chain((extra_fields or {}).items(), kwargs.items())) - def to_dict(self) -> dict[str, Any]: + def to_integration_json(self) -> dict[str, Any]: """ - Convert the generator to a dictionary for json serialization. + 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. """ - data = { + return { "pact:generator:type": self.type, + **self._extra_fields, + **self._integration_fields, + } + + def to_generator_json(self) -> dict[str, Any]: + """ + Convert the generator to a generator JSON object. + + This method is used internally to convert the generator to a JSON object + which can be embedded directly in a number of places in the Pact FFI. + + For more information about this format, see the docs: + + > https://github.com/pact-foundation/pact-specification/tree/version-4 + + and + + > https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers + + Returns: + The generator as a generator JSON object. + """ + return { + "type": self.type, + **self._extra_fields, + **self._generator_json_fields, } - data.update({k: v for k, v in self.extra_args.items() if v is not None}) - return data From a0a3729663debd09d061b942a837da90d206d3ce Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 16:27:04 +1000 Subject: [PATCH 33/43] chore: remove MatchableT As much as it would be nice to restrict a type var to only matchable types, there seems to be an issue when combining typevars with mappings/sequences. So instead, I'm removing `MatchableT` and will re-instate unrestricted `_T` types in the modules. Signed-off-by: JP-Ellis --- src/pact/v3/types.py | 7 +------ src/pact/v3/types.pyi | 32 +------------------------------- 2 files changed, 2 insertions(+), 37 deletions(-) diff --git a/src/pact/v3/types.py b/src/pact/v3/types.py index 26bcc484c..3c831bd5c 100644 --- a/src/pact/v3/types.py +++ b/src/pact/v3/types.py @@ -6,18 +6,13 @@ information to static type checkers like `mypy`. """ -from typing import Any, TypeAlias, TypeVar +from typing import Any, TypeAlias Matchable: TypeAlias = Any """ All supported matchable types. """ -MatchableT = TypeVar("MatchableT") -""" -Matchable type variable. -""" - MatcherType: TypeAlias = str """ All supported matchers. diff --git a/src/pact/v3/types.pyi b/src/pact/v3/types.pyi index 981c9c632..e359d43fa 100644 --- a/src/pact/v3/types.pyi +++ b/src/pact/v3/types.pyi @@ -9,7 +9,7 @@ from collections.abc import Set as AbstractSet from datetime import date, datetime, time from decimal import Decimal from fractions import Fraction -from typing import Literal, TypeAlias, TypeVar +from typing import Literal, TypeAlias from pydantic import BaseModel @@ -52,36 +52,6 @@ Matchable: TypeAlias = ( All supported matchable types. """ -MatchableT = TypeVar( # noqa: PYI001 - "MatchableT", - # BaseMatchable - int, - float, - complex, - bool, - str, - bytes, - bytearray, - memoryview, - None, - # ContainerMatchable - Sequence[Matchable], - AbstractSet[Matchable], - Mapping[Matchable, Matchable], - Collection[Matchable], - # StdlibMatchable - Decimal, - Fraction, - date, - time, - datetime, - # ExtraMatchable - BaseModel, - # This last one silences a number of mypy complaints if trying to have a - # generic `Matcher[Matchable]` type. - Matchable, -) - _MatcherTypeV3: TypeAlias = Literal[ "equality", "regex", From d8b1f3faddad61c3dab8748cc19db0b8381a00f6 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 16:28:48 +1000 Subject: [PATCH 34/43] feat: add each key/value matchers Signed-off-by: JP-Ellis --- src/pact/v3/match/matcher.py | 98 ++++++++++++++++++++++++++++++------ 1 file changed, 84 insertions(+), 14 deletions(-) diff --git a/src/pact/v3/match/matcher.py b/src/pact/v3/match/matcher.py index adbae6691..6bfc07927 100644 --- a/src/pact/v3/match/matcher.py +++ b/src/pact/v3/match/matcher.py @@ -10,18 +10,18 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Mapping from itertools import chain from json import JSONEncoder -from typing import TYPE_CHECKING, Any, Generic +from typing import Any, Generic, TypeVar from pact.v3.generate.generator import Generator -from pact.v3.types import UNSET, Matchable, MatchableT, MatcherType, Unset +from pact.v3.types import UNSET, Matchable, MatcherType, Unset -if TYPE_CHECKING: - from collections.abc import Mapping +_T = TypeVar("_T") -class Matcher(ABC, Generic[MatchableT]): +class Matcher(ABC, Generic[_T]): """ Abstract matcher. @@ -75,7 +75,7 @@ def to_matching_rule(self) -> dict[str, Any]: """ -class GenericMatcher(Matcher[MatchableT]): +class GenericMatcher(Matcher[_T]): """ Generic matcher. @@ -87,8 +87,8 @@ def __init__( # noqa: PLR0913 self, type: MatcherType, # noqa: A002 /, - value: MatchableT | Unset = UNSET, - generator: Generator[MatchableT] | None = None, + value: _T | Unset = UNSET, + generator: Generator | None = None, extra_fields: Mapping[str, Any] | None = None, integration_fields: Mapping[str, Any] | None = None, matching_rule_fields: Mapping[str, Any] | None = None, @@ -133,19 +133,21 @@ def __init__( # noqa: PLR0913 The type of the matcher. """ - self.value: MatchableT | Unset = value + self.value: _T | Unset = value """ Default value used by Pact when executing tests. """ - self.generator: Generator[MatchableT] | None = generator + 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())) + self._integration_fields: Mapping[str, Any] = integration_fields or {} + self._matching_rule_fields: Mapping[str, Any] = matching_rule_fields or {} + self._extra_fields: Mapping[str, Any] = dict( + chain((extra_fields or {}).items(), kwargs.items()) + ) def has_value(self) -> bool: """ @@ -153,7 +155,7 @@ def has_value(self) -> bool: """ return not isinstance(self.value, Unset) - def to_integration_json(self) -> dict[str, Matchable]: + def to_integration_json(self) -> dict[str, Any]: """ Convert the matcher to an integration JSON object. @@ -207,6 +209,74 @@ def to_matching_rule(self) -> dict[str, Any]: } +class EachKeyMatcher(Matcher[Mapping[_T, Matchable]]): + """ + Each key matcher. + + A matcher that applies a matcher to each key in a mapping. + """ + + def __init__( + self, + value: _T, + rules: list[Matcher[_T]] | None = None, + ) -> None: + """ + Initialize the matcher. + + Args: + value: + Example value to match against. + + rules: + List of matchers to apply to each key in the mapping. + """ + self._matcher: Matcher[Mapping[_T, Matchable]] = GenericMatcher( + "eachKey", + extra_fields={"rules": rules, "value": value}, + ) + + def to_integration_json(self) -> dict[str, Any]: # noqa: D102 + return self._matcher.to_integration_json() + + def to_matching_rule(self) -> dict[str, Any]: # noqa: D102 + return self._matcher.to_matching_rule() + + +class EachValueMatcher(Matcher[Mapping[Matchable, _T]]): + """ + Each value matcher. + + A matcher that applies a matcher to each value in a mapping. + """ + + def __init__( + self, + value: _T, + rules: list[Matcher[_T]] | None = None, + ) -> None: + """ + Initialize the matcher. + + Args: + value: + Example value to match against. + + rules: + List of matchers to apply to each value in the mapping. + """ + self._matcher: Matcher[Mapping[Matchable, _T]] = GenericMatcher( + "eachValue", + extra_fields={"rules": rules, "value": value}, + ) + + def to_integration_json(self) -> dict[str, Any]: # noqa: D102 + return self._matcher.to_integration_json() + + def to_matching_rule(self) -> dict[str, Any]: # noqa: D102 + return self._matcher.to_matching_rule() + + class MatchingRuleJSONEncoder(JSONEncoder): """ JSON encoder class for matching rules. From a00267ffe8fafa53ec41777a154d36fe80e799a6 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 16:31:41 +1000 Subject: [PATCH 35/43] refactor: generators module This brings the generators broadly in line with the matchers, including the shadowing of Python builtins to allow for `generate.int()` and `generate.float`, etc. Signed-off-by: JP-Ellis --- src/pact/v3/generate/__init__.py | 315 ++++++++++++++++++++++++------- 1 file changed, 250 insertions(+), 65 deletions(-) diff --git a/src/pact/v3/generate/__init__.py b/src/pact/v3/generate/__init__.py index 2b81ec34f..ee2f36dc3 100644 --- a/src/pact/v3/generate/__init__.py +++ b/src/pact/v3/generate/__init__.py @@ -2,68 +2,161 @@ Generator module. """ -from typing import Literal, Optional +from __future__ import annotations + +import builtins +import warnings +from typing import TYPE_CHECKING, Literal, Mapping, Sequence from pact.v3.generate.generator import ( - ConcreteGenerator, Generator, + GenericGenerator, ) +from pact.v3.util import strftime_to_simple_date_format + +if TYPE_CHECKING: + from types import ModuleType + +# ruff: noqa: A001 +# We provide a more 'Pythonic' interface by matching the names of the +# functions to the types they generate (e.g., `generate.int` generates +# integers). This overrides the built-in types which are accessed via the +# `builtins` module. +# ruff: noqa: A002 +# We only for overrides of built-ins like `min`, `max` and `type` as +# arguments to provide a nicer interface for the user. + +# The Pact specification allows for arbitrary generators to be defined; however +# in practice, only the matchers provided by the FFI are used and supported. +# +# __all__ = [ "Generator", - "random_int", - "random_decimal", - "random_hexadecimal", - "random_string", + "int", + "float", + "hex", + "str", "regex", "uuid", "date", "time", - "date_time", - "random_boolean", + "datetime", + "bool", "provider_state", "mock_server_url", ] +# We prevent users from importing from this module to avoid shadowing built-ins. +__builtins_import = builtins.__import__ + + +def __import__( # noqa: N807 + name: builtins.str, + globals: Mapping[builtins.str, object] | None = None, + locals: Mapping[builtins.str, object] | None = None, + fromlist: Sequence[builtins.str] = (), + level: builtins.int = 0, +) -> ModuleType: + """ + Override to warn when importing functions directly from this module. + + This function is used to override the built-in `__import__` function to + warn users when they import functions directly from this module. This is + done to avoid shadowing built-in types and functions. + """ + if name == "pact.v3.generate" and len(set(fromlist) - {"Matcher"}) > 0: + warnings.warn( + "Avoid `from pact.v3.generate import `. " + "Prefer importing `generate` and use `generate.`", + stacklevel=2, + ) + return __builtins_import(name, globals, locals, fromlist, level) + + +builtins.__import__ = __import__ + -def random_int( - min_val: Optional[int] = None, max_val: Optional[int] = None +def int( + *, + min: builtins.int | None = None, + max: builtins.int | None = None, ) -> Generator: """ Create a random integer generator. Args: - min_val (Optional[int], optional): + min: The minimum value for the integer. - max_val (Optional[int], optional): + max: The maximum value for the integer. """ - return ConcreteGenerator("RandomInt", {"min": min_val, "max": max_val}) + params: dict[builtins.str, builtins.int] = {} + if min is not None: + params["min"] = min + if max is not None: + params["max"] = max + return GenericGenerator("RandomInt", extra_fields=params) + + +def integer( + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> Generator: + """ + Alias for [`generate.int`][pact.v3.generate.int]. + """ + return int(min=min, max=max) -def random_decimal(digits: Optional[int] = None) -> Generator: +def float(precision: builtins.int | None = None) -> Generator: """ Create a random decimal generator. + Note that the precision is the number of digits to generate _in total_, not + the number of decimal places. Therefore a precision of `3` will generate + numbers like `0.123` and `12.3`. + Args: - digits (Optional[int], optional): + precision: The number of digits to generate. """ - return ConcreteGenerator("RandomDecimal", {"digits": digits}) + params: dict[builtins.str, builtins.int] = {} + if precision is not None: + params["digits"] = precision + return GenericGenerator("RandomDecimal", extra_fields=params) + + +def decimal(precision: builtins.int | None = None) -> Generator: + """ + Alias for [`generate.float`][pact.v3.generate.float]. + """ + return float(precision=precision) -def random_hexadecimal(digits: Optional[int] = None) -> Generator: +def hex(digits: builtins.int | None = None) -> Generator: """ Create a random hexadecimal generator. Args: - digits (Optional[int], optional): + digits: The number of digits to generate. """ - return ConcreteGenerator("RandomHexadecimal", {"digits": digits}) + params: dict[builtins.str, builtins.int] = {} + if digits is not None: + params["digits"] = digits + return GenericGenerator("RandomHexadecimal", extra_fields=params) + + +def hexadecimal(digits: builtins.int | None = None) -> Generator: + """ + Alias for [`generate.hex`][pact.v3.generate.hex]. + """ + return hex(digits=digits) -def random_string(size: Optional[int] = None) -> Generator: +def str(size: builtins.int | None = None) -> Generator: """ Create a random string generator. @@ -71,91 +164,173 @@ def random_string(size: Optional[int] = None) -> Generator: size: The size of the string to generate. """ - return ConcreteGenerator("RandomString", {"size": size}) + params: dict[builtins.str, builtins.int] = {} + if size is not None: + params["size"] = size + return GenericGenerator("RandomString", extra_fields=params) -def regex(regex: str) -> Generator: +def string(size: builtins.int | None = None) -> Generator: + """ + Alias for [`generate.str`][pact.v3.generate.str]. + """ + return str(size=size) + + +def regex(regex: builtins.str) -> Generator: """ Create a regex generator. - This will generate a string that matches the given regex. + The generator will generate a string that matches the given regex pattern. Args: - regex (str): + regex: The regex pattern to match. """ - return ConcreteGenerator("Regex", {"regex": regex}) + return GenericGenerator("Regex", {"regex": regex}) + + +_UUID_FORMATS = { + "simple": "simple", + "lowercase": "lower-case-hyphenated", + "uppercase": "upper-case-hyphenated", + "urn": "URN", +} def uuid( - format_str: Optional[ - Literal["simple", "lower-case-hyphenated", "upper-case-hyphenated", "URN"] - ] = None, + format: Literal["simple", "lowercase", "uppercase", "urn"] = "lowercase", ) -> Generator: """ Create a UUID generator. Args: - format_str (Optional[Literal[]], optional): + format: The format of the UUID to generate. This parameter is only supported under the V4 specification. """ - return ConcreteGenerator("Uuid", {"format": format_str}) + return GenericGenerator("Uuid", {"format": format}) -def date(format_str: str) -> Generator: +def date( + format: builtins.str = "%Y-%m-%d", + *, + disable_conversion: builtins.bool = False, +) -> 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. + format: + Expected format of the date. This uses Python's [`strftime` + format](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) + + Pact internally uses the [Java + SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + and the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format is done in + [`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format]. + + If not provided, an ISO 8601 date format is used: `%Y-%m-%d`. + disable_conversion: + If True, the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format will be disabled, and the format must be + in Java's `SimpleDateFormat` format. As a result, the value must + be a string as Python cannot format the date in the target format. """ - return ConcreteGenerator("Date", {"format": format_str}) + if not disable_conversion: + format = strftime_to_simple_date_format(format or "%Y-%m-%d") + return GenericGenerator("Date", {"format": format or "%yyyy-MM-dd"}) -def time(format_str: str) -> Generator: +def time( + format: builtins.str = "%H:%M:%S", + *, + disable_conversion: builtins.bool = False, +) -> 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. + format: + Expected format of the time. This uses Python's [`strftime` + format](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) + + Pact internally uses the [Java + SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + and the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format is done in + [`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format]. + + If not provided, an ISO 8601 time format will be used: `%H:%M:%S`. + disable_conversion: + If True, the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format will be disabled, and the format must be + in Java's `SimpleDateFormat` format. As a result, the value must + be a string as Python cannot format the time in the target format. """ - return ConcreteGenerator("Time", {"format": format_str}) + if not disable_conversion: + format = strftime_to_simple_date_format(format or "%H:%M:%S") + return GenericGenerator("Time", {"format": format or "HH:mm:ss"}) -def date_time(format_str: str) -> Generator: +def datetime( + format: builtins.str, + *, + disable_conversion: builtins.bool = False, +) -> 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. + format: + Expected format of the timestamp. This uses Python's [`strftime` + format](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) + + Pact internally uses the [Java + SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html) + and the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format is done in + [`strftime_to_simple_date_format`][pact.v3.util.strftime_to_simple_date_format]. + + If not provided, an ISO 8601 timestamp format will be used: + `%Y-%m-%dT%H:%M:%S`. + disable_conversion: + If True, the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format will be disabled, and the format must be + in Java's `SimpleDateFormat` format. As a result, the value must be """ - return ConcreteGenerator("DateTime", {"format": format_str}) + if not disable_conversion: + format = strftime_to_simple_date_format(format or "%Y-%m-%dT%H:%M:%S") + return GenericGenerator("DateTime", {"format": format or "yyyy-MM-dd'T'HH:mm:ss"}) -def random_boolean() -> Generator: +def timestamp( + format: builtins.str, + *, + disable_conversion: builtins.bool = False, +) -> Generator: + """ + Alias for [`generate.datetime`][pact.v3.generate.datetime]. + """ + return datetime(format=format, disable_conversion=disable_conversion) + + +def bool() -> Generator: """ Create a random boolean generator. """ - return ConcreteGenerator("RandomBoolean") + return GenericGenerator("RandomBoolean") -def provider_state(expression: Optional[str] = None) -> Generator: +def boolean() -> Generator: + """ + Alias for [`generate.bool`][pact.v3.generate.bool]. + """ + return bool() + + +def provider_state(expression: builtins.str | None = None) -> Generator: """ Create a provider state generator. @@ -163,14 +338,18 @@ def provider_state(expression: Optional[str] = None) -> Generator: using the given expression. Args: - expression (Optional[str], optional): + expression: The expression to use to look up the provider state. """ - return ConcreteGenerator("ProviderState", {"expression": expression}) + params: dict[builtins.str, builtins.str] = {} + if expression is not None: + params["expression"] = expression + return GenericGenerator("ProviderState", extra_fields=params) -def mock_server_url( - regex: Optional[str] = None, example: Optional[str] = None +def mock_server_url( # noqa: D417 (false positive) + regex: builtins.str | None = None, + example: builtins.str | None = None, ) -> Generator: """ Create a mock server URL generator. @@ -178,9 +357,15 @@ def mock_server_url( Generates a URL with the mock server as the base URL. Args: - regex (Optional[str], optional): + regex: The regex pattern to match. - example (Optional[str], optional): + + example: An example URL to use. - """ - return ConcreteGenerator("MockServerURL", {"regex": regex, "example": example}) + """ # noqa: D214, D405 (false positive) + params: dict[builtins.str, builtins.str] = {} + if regex is not None: + params["regex"] = regex + if example is not None: + params["example"] = example + return GenericGenerator("MockServerURL", extra_fields=params) From 5f034815970e9f3777998ba2b952fbf884b37731 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 16:31:59 +1000 Subject: [PATCH 36/43] refactor: match module This incorporates a couple of recent changes in the recent of the codebase: - The addition of EachKeyMatcher and EachValueMatcher - The refactor of the generate module - The removal of the MatchableT TypeVar Signed-off-by: JP-Ellis --- src/pact/v3/match/__init__.py | 63 +++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index e2090054d..48b480c3b 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -46,8 +46,13 @@ from typing import TYPE_CHECKING, Literal, Mapping, Sequence, TypeVar, overload from pact.v3 import generate -from pact.v3.match.matcher import GenericMatcher, Matcher -from pact.v3.types import UNSET, Matchable, MatchableT, Unset +from pact.v3.match.matcher import ( + EachKeyMatcher, + EachValueMatcher, + GenericMatcher, + Matcher, +) +from pact.v3.types import UNSET, Matchable, Unset from pact.v3.util import strftime_to_simple_date_format if TYPE_CHECKING: @@ -92,6 +97,8 @@ "each_value_matches", ] +_T = TypeVar("_T") + # We prevent users from importing from this module to avoid shadowing built-ins. __builtins_import = builtins.__import__ @@ -144,7 +151,7 @@ def int( return GenericMatcher( "integer", value=value, - generator=generate.random_int(min, max), + generator=generate.int(min=min, max=max), ) @@ -182,7 +189,7 @@ def float( return GenericMatcher( "decimal", value, - generator=generate.random_decimal(precision), + generator=generate.float(precision), ) @@ -264,7 +271,7 @@ def number( return GenericMatcher( "number", value=value, - generator=generate.random_int(min, max), + generator=generate.int(min=min, max=max), ) if isinstance(value, builtins.float): @@ -276,7 +283,7 @@ def number( return GenericMatcher( "number", value=value, - generator=generate.random_decimal(precision), + generator=generate.float(precision), ) if isinstance(value, Decimal): @@ -288,7 +295,7 @@ def number( return GenericMatcher( "number", value=value, - generator=generate.random_decimal(precision), + generator=generate.float(precision), ) msg = f"Unsupported number type: {builtins.type(value)}" @@ -325,7 +332,7 @@ def str( return GenericMatcher( "type", value=value, - generator=generator or generate.random_string(size), + generator=generator or generate.str(size), ) @@ -418,7 +425,7 @@ def uuid( "regex", value=value, regex=pattern, - generator=generate.uuid(format), + generator=generate.uuid(format or "lowercase"), ) @@ -430,7 +437,7 @@ def bool(value: builtins.bool | Unset = UNSET, /) -> Matcher[builtins.bool]: value: Default value to use when generating a consumer test. """ - return GenericMatcher("boolean", value, generator=generate.random_boolean()) + return GenericMatcher("boolean", value, generator=generate.bool()) def date( @@ -586,7 +593,7 @@ def datetime( "timestamp", value=value, format=format, - generator=generate.date_time( + generator=generate.datetime( format or "yyyy-MM-dd'T'HH:mm:ss", disable_conversion=True, ), @@ -599,7 +606,7 @@ def datetime( "timestamp", value=value, format=format, - generator=generate.date_time(format, disable_conversion=True), + generator=generate.datetime(format, disable_conversion=True), ) @@ -631,13 +638,13 @@ def null() -> Matcher[None]: def type( - value: MatchableT, + value: _T, /, *, min: builtins.int | None = None, max: builtins.int | None = None, generator: Generator | None = None, -) -> Matcher[MatchableT]: +) -> Matcher[_T]: """ Match a value by type. @@ -656,13 +663,13 @@ def type( def like( - value: MatchableT, + value: _T, /, *, min: builtins.int | None = None, max: builtins.int | None = None, generator: Generator | None = None, -) -> Matcher[MatchableT]: +) -> Matcher[_T]: """ Alias for [`match.type`][pact.v3.match.type]. """ @@ -670,12 +677,12 @@ def like( def each_like( - value: MatchableT, + value: _T, /, *, min: builtins.int | None = None, max: builtins.int | None = None, -) -> Matcher[Sequence[MatchableT]]: +) -> Matcher[Sequence[_T]]: # type: ignore[type-var] """ Match each item in an array against a given value. @@ -695,7 +702,7 @@ def each_like( "The minimum number of items must be at least 1.", stacklevel=2, ) - return GenericMatcher("type", value=[value], min=min, max=max) + return GenericMatcher("type", value=[value], min=min, max=max) # type: ignore[return-value] def includes( @@ -720,7 +727,7 @@ def includes( ) -def array_containing(variants: list[Matchable], /) -> Matcher[Matchable]: +def array_containing(variants: Sequence[Matchable], /) -> Matcher[Matchable]: """ Match an array that contains the given variants. @@ -735,11 +742,11 @@ def array_containing(variants: list[Matchable], /) -> Matcher[Matchable]: def each_key_matches( - value: MatchableT, + value: _T, /, *, - rules: Matcher[Matchable] | list[Matcher[Matchable]], -) -> Matcher[Mapping[MatchableT, Matchable]]: + rules: Matcher[_T] | list[Matcher[_T]], +) -> Matcher[Mapping[_T, Matchable]]: """ Match each key in a dictionary against a set of rules. @@ -751,15 +758,15 @@ def each_key_matches( """ if isinstance(rules, Matcher): rules = [rules] - return GenericMatcher("eachKey", value=value, rules=rules) + return EachKeyMatcher(value=value, rules=rules) def each_value_matches( - value: MatchableT, + value: _T, /, *, - rules: Matcher[Matchable] | list[Matcher[Matchable]], -) -> Matcher[Mapping[Matchable, MatchableT]]: + rules: Matcher[_T] | list[Matcher[_T]], +) -> Matcher[Mapping[Matchable, _T]]: """ Returns a matcher that matches each value in a dictionary against a set of rules. @@ -771,4 +778,4 @@ def each_value_matches( """ if isinstance(rules, Matcher): rules = [rules] - return GenericMatcher("eachValue", value=value, rules=rules) + return EachValueMatcher(value=value, rules=rules) From 1f2fb0b4b24eba13f6e79252bccb893f0c13edd6 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 17:07:04 +1000 Subject: [PATCH 37/43] chore: get test to run again Signed-off-by: JP-Ellis --- .../v3/{test_matchers.py => test_match.py} | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) rename examples/tests/v3/{test_matchers.py => test_match.py} (79%) diff --git a/examples/tests/v3/test_matchers.py b/examples/tests/v3/test_match.py similarity index 79% rename from examples/tests/v3/test_matchers.py rename to examples/tests/v3/test_match.py index 615f3a36b..1971746ee 100644 --- a/examples/tests/v3/test_matchers.py +++ b/examples/tests/v3/test_match.py @@ -8,7 +8,7 @@ import requests from examples.tests.v3.basic_flask_server import start_provider -from pact.v3 import Pact, Verifier, generators, match +from pact.v3 import Pact, Verifier, generate, match def test_matchers() -> None: @@ -17,15 +17,15 @@ def test_matchers() -> None: ( pact.upon_receiving("a request") .given("a state", parameters={"providerStateArgument": "providerStateValue"}) - .with_request("GET", match.regex(r"/path/to/\d{1,4}", "/path/to/100")) + .with_request("GET", match.regex("/path/to/100", regex=r"/path/to/\d{1,4}")) .with_query_parameter( "asOf", match.like( [ match.date("yyyy-MM-dd", "2024-01-01"), ], - min_count=1, - max_count=1, + min=1, + max=1, ), ) .will_respond_with(200) @@ -33,35 +33,37 @@ def test_matchers() -> None: "response": match.like( { "regexMatches": match.regex( - r".*hello world'$", "must end with 'hello world'" + "must end with 'hello world'", + regex=r"^.*hello world'$", ), "randomRegexMatches": match.regex( r"1-8 digits: \d{1,8}, 1-8 random letters \w{1,8}" ), - "integerMatches": match.integer(42), + "integerMatches": match.int(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), + "randomIntegerMatches": match.int(min=1, max=100), + "randomDecimalMatches": match.decimal(precision=4), + "booleanMatches": match.bool(False), "randomStringMatches": match.string(size=10), "includeMatches": match.includes("world"), "includeWithGeneratorMatches": match.includes( - "world", generators.regex(r"\d{1,8} (hello )?world \d+") + "world", + generator=generate.regex(r"\d{1,8} (hello )?world \d+"), ), "minMaxArrayMatches": match.each_like( - match.number(digits=2), - min_count=3, - max_count=5, + match.number(1.23, precision=2), + min=3, + max=5, ), "arrayContainingMatches": match.array_containing([ - match.integer(1), - match.integer(2), + match.int(1), + match.int(2), ]), "numbers": { "intMatches": match.number(42), "floatMatches": match.number(3.1415), - "intGeneratorMatches": match.number(max_val=10), - "decimalGeneratorMatches": match.number(digits=4), + "intGeneratorMatches": match.number(2, max=10), + "decimalGeneratorMatches": match.number(3.1415, precision=4), }, "dateMatches": match.date("yyyy-MM-dd", "2024-01-01"), "randomDateMatches": match.date("yyyy-MM-dd"), @@ -79,13 +81,15 @@ def test_matchers() -> None: rules=match.string("John Doe"), ) }, - rules=match.regex(r"id_\d+", "id_1"), + rules=match.regex("id_1", regex=r"^id_\d+$"), ), }, - min_count=1, + min=1, ) }) - .with_header("SpecialHeader", match.regex(r"Special: \w+", "Special: Foo")) + .with_header( + "SpecialHeader", match.regex("Special: Foo", regex=r"%Special: \w+") + ) ) with pact.serve() as mockserver: response = requests.get( From 4950f506313b5e97dc43287ce97951df47fa3bf5 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 17:07:45 +1000 Subject: [PATCH 38/43] feat: add ArrayContainsMatcher Signed-off-by: JP-Ellis --- src/pact/v3/match/__init__.py | 5 +++-- src/pact/v3/match/matcher.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index 48b480c3b..dd56fe0ef 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -47,6 +47,7 @@ from pact.v3 import generate from pact.v3.match.matcher import ( + ArrayContainsMatcher, EachKeyMatcher, EachValueMatcher, GenericMatcher, @@ -727,7 +728,7 @@ def includes( ) -def array_containing(variants: Sequence[Matchable], /) -> Matcher[Matchable]: +def array_containing(variants: Sequence[_T | Matcher[_T]], /) -> Matcher[Sequence[_T]]: """ Match an array that contains the given variants. @@ -738,7 +739,7 @@ def array_containing(variants: Sequence[Matchable], /) -> Matcher[Matchable]: variants: A list of variants to match against. """ - return GenericMatcher("arrayContains", variants=variants) + return ArrayContainsMatcher(variants=variants) def each_key_matches( diff --git a/src/pact/v3/match/matcher.py b/src/pact/v3/match/matcher.py index 6bfc07927..64e63fedd 100644 --- a/src/pact/v3/match/matcher.py +++ b/src/pact/v3/match/matcher.py @@ -13,7 +13,7 @@ from collections.abc import Mapping from itertools import chain from json import JSONEncoder -from typing import Any, Generic, TypeVar +from typing import Any, Generic, Sequence, TypeVar from pact.v3.generate.generator import Generator from pact.v3.types import UNSET, Matchable, MatcherType, Unset @@ -243,6 +243,33 @@ def to_matching_rule(self) -> dict[str, Any]: # noqa: D102 return self._matcher.to_matching_rule() +class ArrayContainsMatcher(Matcher[Sequence[_T]]): + """ + Array contains matcher. + + A matcher that checks if an array contains a value. + """ + + def __init__(self, variants: Sequence[_T | Matcher[_T]]) -> None: + """ + Initialize the matcher. + + Args: + variants: + List of possible values to match against. + """ + self._matcher: Matcher[Sequence[_T]] = GenericMatcher( + "arrayContains", + extra_fields={"variants": variants}, + ) + + def to_integration_json(self) -> dict[str, Any]: # noqa: D102 + return self._matcher.to_integration_json() + + def to_matching_rule(self) -> dict[str, Any]: # noqa: D102 + return self._matcher.to_matching_rule() + + class EachValueMatcher(Matcher[Mapping[Matchable, _T]]): """ Each value matcher. From 9814b3dafb427cc1a7397f1791b69542ba947726 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Oct 2024 17:07:55 +1000 Subject: [PATCH 39/43] chore: add boolean alias Signed-off-by: JP-Ellis --- src/pact/v3/match/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index dd56fe0ef..8d237d4f8 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -441,6 +441,13 @@ def bool(value: builtins.bool | Unset = UNSET, /) -> Matcher[builtins.bool]: return GenericMatcher("boolean", value, generator=generate.bool()) +def boolean(value: builtins.bool | Unset = UNSET, /) -> Matcher[builtins.bool]: + """ + Alias for [`match.bool`][pact.v3.match.bool]. + """ + return bool(value) + + def date( value: dt.date | builtins.str | Unset = UNSET, /, From 1c6170c97b1987d57ee726ddf512155d3e74fd8e Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 2 Oct 2024 11:08:08 +1000 Subject: [PATCH 40/43] chore: fix compatibility with Python <= 3.9 Signed-off-by: JP-Ellis --- src/pact/v3/types.py | 4 +++- src/pact/v3/types.pyi | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pact/v3/types.py b/src/pact/v3/types.py index 3c831bd5c..d4bd0e6a0 100644 --- a/src/pact/v3/types.py +++ b/src/pact/v3/types.py @@ -6,7 +6,9 @@ information to static type checkers like `mypy`. """ -from typing import Any, TypeAlias +from typing import Any + +from typing_extensions import TypeAlias Matchable: TypeAlias = Any """ diff --git a/src/pact/v3/types.pyi b/src/pact/v3/types.pyi index e359d43fa..e29d6bd41 100644 --- a/src/pact/v3/types.pyi +++ b/src/pact/v3/types.pyi @@ -9,9 +9,10 @@ from collections.abc import Set as AbstractSet from datetime import date, datetime, time from decimal import Decimal from fractions import Fraction -from typing import Literal, TypeAlias +from typing import Literal from pydantic import BaseModel +from typing_extensions import TypeAlias _BaseMatchable: TypeAlias = ( int | float | complex | bool | str | bytes | bytearray | memoryview | None From ba8c3c8796f9516a7adae8cfccdbe5e21e5e0f7c Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 2 Oct 2024 11:39:38 +1000 Subject: [PATCH 41/43] fix: ensure matchers optionally use generators A generator is only really useful if a value is _not_ provided. Otherwise, the generator is likely to cause confusion when the test doesn't generator the expected result. Signed-off-by: JP-Ellis --- src/pact/v3/match/__init__.py | 145 ++++++++++++++++++++++++++-------- 1 file changed, 111 insertions(+), 34 deletions(-) diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index 8d237d4f8..48e1c8b71 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -5,11 +5,11 @@ within a Pact contract. These rules define the expected content of the data being exchanged in a way that is more flexible than a simple equality check. -As an example, a contract may define how a new record is to be created through -a POST request. The consumer would define the new information to be sent, and -the expected response. The response may contain additional data added by the -provider, such as an ID and a creation timestamp. The contract would define -that the ID is of a specific format (e.g., an integer or a UUID), and that the +As an example, a contract may define how a new record is to be created through a +POST request. The consumer would define the new information to be sent, and the +expected response. The response may contain additional data added by the +provider, such as an ID and a creation timestamp. The contract would define that +the ID is of a specific format (e.g., an integer or a UUID), and that the creation timestamp is ISO 8601 formatted. !!! warning @@ -29,12 +29,17 @@ int(...) ``` -A number of functions in this module are named after the types they match -(e.g., `int`, `str`, `bool`). These functions will have aliases as well for -better interoperability with the rest of the Pact ecosystem. It is important -to note that these functions will shadow the built-in types if imported directly -from this module. This is why we recommend importing the `match` module and -using the functions from there. +A number of functions in this module are named after the types they match (e.g., +`int`, `str`, `bool`). These functions will have aliases as well for better +interoperability with the rest of the Pact ecosystem. It is important to note +that these functions will shadow the built-in types if imported directly from +this module. This is why we recommend importing the `match` module and using the +functions from there. + +Matching rules are frequently combined with generators which allow for Pact to +generate values on the fly during contract testing. As a general rule for the +functions below, if a `value` is _not_ provided, a generator will be used; and +conversely, if a `value` is provided, a generator will _not_ be used. """ from __future__ import annotations @@ -149,10 +154,14 @@ def int( max: If provided, the maximum value of the integer to generate. """ + if value is UNSET: + return GenericMatcher( + "integer", + generator=generate.int(min=min, max=max), + ) return GenericMatcher( "integer", value=value, - generator=generate.int(min=min, max=max), ) @@ -187,10 +196,14 @@ def float( precision: The number of decimal precision to generate. """ + if value is UNSET: + return GenericMatcher( + "decimal", + generator=generate.float(precision), + ) return GenericMatcher( "decimal", value, - generator=generate.float(precision), ) @@ -248,8 +261,6 @@ def number( and [`decimal`][pact.v3.match.decimal] matchers. It can be used to match any number, whether it is an integer or a float. - Th - Args: value: Default value to use when generating a consumer test. @@ -263,6 +274,16 @@ def number( The number of decimal digits to generate. Only used when value is a float. Defaults to None. """ + if value is UNSET: + if min is not None or max is not None: + generator = generate.int(min=min, max=max) + elif precision is not None: + generator = generate.float(precision) + else: + msg = "At least one of min, max, or precision must be provided." + raise ValueError(msg) + return GenericMatcher("number", generator=generator) + if isinstance(value, builtins.int): if precision is not None: warnings.warn( @@ -272,7 +293,6 @@ def number( return GenericMatcher( "number", value=value, - generator=generate.int(min=min, max=max), ) if isinstance(value, builtins.float): @@ -284,7 +304,6 @@ def number( return GenericMatcher( "number", value=value, - generator=generate.float(precision), ) if isinstance(value, Decimal): @@ -296,7 +315,6 @@ def number( return GenericMatcher( "number", value=value, - generator=generate.float(precision), ) msg = f"Unsupported number type: {builtins.type(value)}" @@ -325,15 +343,25 @@ def str( generator: Alternative generator to use when generating a consumer test. """ - if size and generator: + if value is UNSET: + if size and generator: + warnings.warn( + "The size argument is ignored when a generator is provided.", + stacklevel=2, + ) + return GenericMatcher( + "type", + generator=generator or generate.str(size), + ) + + if size is not None or generator: warnings.warn( - "The size argument is ignored when a generator is provided.", + "The size and generator arguments are ignored when a value is provided.", stacklevel=2, ) return GenericMatcher( "type", value=value, - generator=generator or generate.str(size), ) @@ -368,10 +396,16 @@ def regex( if regex is None: msg = "A regex pattern must be provided." raise ValueError(msg) + + if value is UNSET: + return GenericMatcher( + "regex", + generator=generate.regex(regex), + regex=regex, + ) return GenericMatcher( "regex", value, - generator=generate.regex(regex), regex=regex, ) @@ -422,11 +456,16 @@ def uuid( if format else rf"^({_UUID_FORMATS['lowercase']}|{_UUID_FORMATS['uppercase']})$" ) + if value is UNSET: + return GenericMatcher( + "regex", + generator=generate.uuid(format or "lowercase"), + regex=pattern, + ) return GenericMatcher( "regex", value=value, regex=pattern, - generator=generate.uuid(format or "lowercase"), ) @@ -438,7 +477,9 @@ def bool(value: builtins.bool | Unset = UNSET, /) -> Matcher[builtins.bool]: value: Default value to use when generating a consumer test. """ - return GenericMatcher("boolean", value, generator=generate.bool()) + if value is UNSET: + return GenericMatcher("boolean", generator=generate.bool()) + return GenericMatcher("boolean", value) def boolean(value: builtins.bool | Unset = UNSET, /) -> Matcher[builtins.bool]: @@ -485,22 +526,34 @@ def date( if not isinstance(value, builtins.str): msg = "When disable_conversion is True, the value must be a string." raise ValueError(msg) + format = format or "yyyy-MM-dd" + if value is UNSET: + return GenericMatcher( + "date", + format=format, + generator=generate.date(format, disable_conversion=True), + ) return GenericMatcher( "date", value=value, format=format, - generator=generate.date(format or "yyyy-MM-dd", disable_conversion=True), ) format = format or "%Y-%m-%d" if isinstance(value, dt.date): value = value.strftime(format) format = strftime_to_simple_date_format(format) + + if value is UNSET: + return GenericMatcher( + "date", + format=format, + generator=generate.date(format, disable_conversion=True), + ) return GenericMatcher( "date", value=value, format=format, - generator=generate.date(format, disable_conversion=True), ) @@ -541,21 +594,32 @@ def time( if not isinstance(value, builtins.str): msg = "When disable_conversion is True, the value must be a string." raise ValueError(msg) + format = format or "HH:mm:ss" + if value is UNSET: + return GenericMatcher( + "time", + format=format, + generator=generate.time(format, disable_conversion=True), + ) return GenericMatcher( "time", value=value, format=format, - generator=generate.time(format or "HH:mm:ss", disable_conversion=True), ) format = format or "%H:%M:%S" if isinstance(value, dt.time): value = value.strftime(format) format = strftime_to_simple_date_format(format) + if value is UNSET: + return GenericMatcher( + "time", + format=format, + generator=generate.time(format, disable_conversion=True), + ) return GenericMatcher( "time", value=value, format=format, - generator=generate.time(format, disable_conversion=True), ) @@ -597,24 +661,32 @@ def datetime( if not isinstance(value, builtins.str): msg = "When disable_conversion is True, the value must be a string." raise ValueError(msg) + format = format or "yyyy-MM-dd'T'HH:mm:ss" + if value is UNSET: + return GenericMatcher( + "timestamp", + format=format, + generator=generate.datetime(format, disable_conversion=True), + ) return GenericMatcher( "timestamp", value=value, format=format, - generator=generate.datetime( - format or "yyyy-MM-dd'T'HH:mm:ss", - disable_conversion=True, - ), ) format = format or "%Y-%m-%dT%H:%M:%S" if isinstance(value, dt.datetime): value = value.strftime(format) format = strftime_to_simple_date_format(format) + if value is UNSET: + return GenericMatcher( + "timestamp", + format=format, + generator=generate.datetime(format, disable_conversion=True), + ) return GenericMatcher( "timestamp", value=value, format=format, - generator=generate.datetime(format, disable_conversion=True), ) @@ -628,7 +700,7 @@ def timestamp( """ Alias for [`match.datetime`][pact.v3.match.datetime]. """ - return timestamp(value, format, disable_conversion=disable_conversion) + return datetime(value, format, disable_conversion=disable_conversion) def none() -> Matcher[None]: @@ -667,6 +739,11 @@ def type( generator: The generator to use when generating the value. """ + if value is UNSET: + if not generator: + msg = "A generator must be provided when value is not set." + raise ValueError(msg) + return GenericMatcher("type", min=min, max=max, generator=generator) return GenericMatcher("type", value, min=min, max=max, generator=generator) From 57d4405e2e1b1a58b19a5c56b3aa89198fe6ede8 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 2 Oct 2024 11:39:51 +1000 Subject: [PATCH 42/43] chore: fix match tests Signed-off-by: JP-Ellis --- examples/tests/v3/test_match.py | 23 ++++++------- src/pact/v3/generate/__init__.py | 8 ++++- src/pact/v3/match/__init__.py | 22 ++++++++----- src/pact/v3/match/matcher.py | 56 +++++++++++++++++--------------- 4 files changed, 60 insertions(+), 49 deletions(-) diff --git a/examples/tests/v3/test_match.py b/examples/tests/v3/test_match.py index 1971746ee..bb8dafeb9 100644 --- a/examples/tests/v3/test_match.py +++ b/examples/tests/v3/test_match.py @@ -21,9 +21,7 @@ def test_matchers() -> None: .with_query_parameter( "asOf", match.like( - [ - match.date("yyyy-MM-dd", "2024-01-01"), - ], + [match.date("2024-01-01", format="%Y-%m-%d")], min=1, max=1, ), @@ -37,7 +35,7 @@ def test_matchers() -> None: regex=r"^.*hello world'$", ), "randomRegexMatches": match.regex( - r"1-8 digits: \d{1,8}, 1-8 random letters \w{1,8}" + regex=r"1-8 digits: \d{1,8}, 1-8 random letters \w{1,8}" ), "integerMatches": match.int(42), "decimalMatches": match.decimal(3.1415), @@ -65,19 +63,18 @@ def test_matchers() -> None: "intGeneratorMatches": match.number(2, max=10), "decimalGeneratorMatches": match.number(3.1415, precision=4), }, - "dateMatches": match.date("yyyy-MM-dd", "2024-01-01"), - "randomDateMatches": match.date("yyyy-MM-dd"), - "timeMatches": match.time("HH:mm:ss", "12:34:56"), + "dateMatches": match.date("2024-01-01", format="%Y-%m-%d"), + "randomDateMatches": match.date(format="%Y-%m-%d"), + "timeMatches": match.time("12:34:56", format="%H:%M:%S"), "timestampMatches": match.timestamp( - "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "2024-01-01T12:34:56.000000" + "2024-01-01T12:34:56.000000", + format="%Y-%m-%dT%H:%M:%S.%f", ), "nullMatches": match.null(), "eachKeyMatches": match.each_key_matches( { "id_1": match.each_value_matches( - { - "name": match.string(size=30), - }, + {"name": match.string(size=30)}, rules=match.string("John Doe"), ) }, @@ -88,7 +85,7 @@ def test_matchers() -> None: ) }) .with_header( - "SpecialHeader", match.regex("Special: Foo", regex=r"%Special: \w+") + "SpecialHeader", match.regex("Special: Foo", regex=r"^Special: \w+$") ) ) with pact.serve() as mockserver: @@ -110,7 +107,7 @@ def test_matchers() -> None: == "2024-01-01T12:34:56.000000" ) assert response_data["response"]["arrayContainingMatches"] == [1, 2] - assert response_data["response"]["nullMatches"] == "" + assert response_data["response"]["nullMatches"] is None # 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}" diff --git a/src/pact/v3/generate/__init__.py b/src/pact/v3/generate/__init__.py index ee2f36dc3..9c1f6d6c7 100644 --- a/src/pact/v3/generate/__init__.py +++ b/src/pact/v3/generate/__init__.py @@ -32,19 +32,25 @@ # # __all__ = [ - "Generator", "int", + "integer", "float", + "decimal", "hex", + "hexadecimal", "str", + "string", "regex", "uuid", "date", "time", "datetime", + "timestamp", "bool", + "boolean", "provider_state", "mock_server_url", + "Generator", ] # We prevent users from importing from this module to avoid shadowing built-ins. diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py index 48e1c8b71..722092989 100644 --- a/src/pact/v3/match/__init__.py +++ b/src/pact/v3/match/__init__.py @@ -48,7 +48,7 @@ import datetime as dt import warnings from decimal import Decimal -from typing import TYPE_CHECKING, Literal, Mapping, Sequence, TypeVar, overload +from typing import TYPE_CHECKING, Any, Literal, Mapping, Sequence, TypeVar, overload from pact.v3 import generate from pact.v3.match.matcher import ( @@ -81,18 +81,22 @@ # # __all__ = [ - "Matcher", "int", - "decimal", + "integer", "float", + "decimal", "number", "str", + "string", "regex", + "uuid", "bool", + "boolean", "date", "time", "datetime", "timestamp", + "none", "null", "type", "like", @@ -101,6 +105,7 @@ "array_containing", "each_key_matches", "each_value_matches", + "Matcher", ] _T = TypeVar("_T") @@ -338,10 +343,10 @@ def str( value: Default value to use when generating a consumer test. size: - If no generator is provided, the size of the string to generate - during a consumer test. + The size of the string to generate during a consumer test. generator: - Alternative generator to use when generating a consumer test. + Alternative generator to use when generating a consumer test. If + set, the `size` argument is ignored. """ if value is UNSET: if size and generator: @@ -351,6 +356,7 @@ def str( ) return GenericMatcher( "type", + value="string", generator=generator or generate.str(size), ) @@ -827,7 +833,7 @@ def array_containing(variants: Sequence[_T | Matcher[_T]], /) -> Matcher[Sequenc def each_key_matches( - value: _T, + value: Mapping[_T, Any], /, *, rules: Matcher[_T] | list[Matcher[_T]], @@ -847,7 +853,7 @@ def each_key_matches( def each_value_matches( - value: _T, + value: Mapping[Any, _T], /, *, rules: Matcher[_T] | list[Matcher[_T]], diff --git a/src/pact/v3/match/matcher.py b/src/pact/v3/match/matcher.py index 64e63fedd..ca83c5ff4 100644 --- a/src/pact/v3/match/matcher.py +++ b/src/pact/v3/match/matcher.py @@ -209,31 +209,24 @@ def to_matching_rule(self) -> dict[str, Any]: } -class EachKeyMatcher(Matcher[Mapping[_T, Matchable]]): +class ArrayContainsMatcher(Matcher[Sequence[_T]]): """ - Each key matcher. + Array contains matcher. - A matcher that applies a matcher to each key in a mapping. + A matcher that checks if an array contains a value. """ - def __init__( - self, - value: _T, - rules: list[Matcher[_T]] | None = None, - ) -> None: + def __init__(self, variants: Sequence[_T | Matcher[_T]]) -> None: """ Initialize the matcher. Args: - value: - Example value to match against. - - rules: - List of matchers to apply to each key in the mapping. + variants: + List of possible values to match against. """ - self._matcher: Matcher[Mapping[_T, Matchable]] = GenericMatcher( - "eachKey", - extra_fields={"rules": rules, "value": value}, + self._matcher: Matcher[Sequence[_T]] = GenericMatcher( + "arrayContains", + extra_fields={"variants": variants}, ) def to_integration_json(self) -> dict[str, Any]: # noqa: D102 @@ -243,24 +236,32 @@ def to_matching_rule(self) -> dict[str, Any]: # noqa: D102 return self._matcher.to_matching_rule() -class ArrayContainsMatcher(Matcher[Sequence[_T]]): +class EachKeyMatcher(Matcher[Mapping[_T, Matchable]]): """ - Array contains matcher. + Each key matcher. - A matcher that checks if an array contains a value. + A matcher that applies a matcher to each key in a mapping. """ - def __init__(self, variants: Sequence[_T | Matcher[_T]]) -> None: + def __init__( + self, + value: Mapping[_T, Matchable], + rules: list[Matcher[_T]] | None = None, + ) -> None: """ Initialize the matcher. Args: - variants: - List of possible values to match against. + value: + Example value to match against. + + rules: + List of matchers to apply to each key in the mapping. """ - self._matcher: Matcher[Sequence[_T]] = GenericMatcher( - "arrayContains", - extra_fields={"variants": variants}, + self._matcher: Matcher[Mapping[_T, Matchable]] = GenericMatcher( + "eachKey", + value=value, + extra_fields={"rules": rules}, ) def to_integration_json(self) -> dict[str, Any]: # noqa: D102 @@ -279,7 +280,7 @@ class EachValueMatcher(Matcher[Mapping[Matchable, _T]]): def __init__( self, - value: _T, + value: Mapping[Matchable, _T], rules: list[Matcher[_T]] | None = None, ) -> None: """ @@ -294,7 +295,8 @@ def __init__( """ self._matcher: Matcher[Mapping[Matchable, _T]] = GenericMatcher( "eachValue", - extra_fields={"rules": rules, "value": value}, + value=value, + extra_fields={"rules": rules}, ) def to_integration_json(self) -> dict[str, Any]: # noqa: D102 From a450209037353e77f503dce6fb14da160a2c21df Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 2 Oct 2024 13:38:54 +1000 Subject: [PATCH 43/43] chore: remove unnused generalisation The ability to add additional fields to either the integration JSON or FFI JSON representations is not used, and may never be needed. Signed-off-by: JP-Ellis --- src/pact/v3/generate/generator.py | 14 -------------- src/pact/v3/match/matcher.py | 16 +--------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/src/pact/v3/generate/generator.py b/src/pact/v3/generate/generator.py index 21d5c3434..633740be6 100644 --- a/src/pact/v3/generate/generator.py +++ b/src/pact/v3/generate/generator.py @@ -79,8 +79,6 @@ def __init__( type: GeneratorType, # noqa: A002 /, extra_fields: Mapping[str, Any] | None = None, - integration_fields: Mapping[str, Any] | None = None, - generator_json_fields: Mapping[str, Any] | None = None, **kwargs: Any, # noqa: ANN401 ) -> None: """ @@ -95,14 +93,6 @@ def __init__( These fields will be used when converting the generator to both an integration JSON object and a generator JSON object. - integration_fields: - Additional configuration elements to pass to the generator when - converting to an integration JSON object. - - generator_json_fields: - Additional configuration elements to pass to the generator when - converting to a generator JSON object. - **kwargs: Alternative way to pass additional configuration elements to the generator. See the `extra_fields` argument for more information. @@ -112,8 +102,6 @@ def __init__( The type of the generator. """ - self._integration_fields = integration_fields or {} - self._generator_json_fields = generator_json_fields or {} self._extra_fields = dict(chain((extra_fields or {}).items(), kwargs.items())) def to_integration_json(self) -> dict[str, Any]: @@ -133,7 +121,6 @@ def to_integration_json(self) -> dict[str, Any]: return { "pact:generator:type": self.type, **self._extra_fields, - **self._integration_fields, } def to_generator_json(self) -> dict[str, Any]: @@ -157,5 +144,4 @@ def to_generator_json(self) -> dict[str, Any]: return { "type": self.type, **self._extra_fields, - **self._generator_json_fields, } diff --git a/src/pact/v3/match/matcher.py b/src/pact/v3/match/matcher.py index ca83c5ff4..1a2499191 100644 --- a/src/pact/v3/match/matcher.py +++ b/src/pact/v3/match/matcher.py @@ -83,15 +83,13 @@ class GenericMatcher(Matcher[_T]): for inclusion in the integration JSON object and matching rule. """ - def __init__( # noqa: PLR0913 + def __init__( self, type: MatcherType, # noqa: A002 /, value: _T | Unset = UNSET, generator: Generator | None = None, extra_fields: Mapping[str, Any] | None = None, - integration_fields: Mapping[str, Any] | None = None, - matching_rule_fields: Mapping[str, Any] | None = None, **kwargs: Matchable, ) -> None: """ @@ -116,14 +114,6 @@ def __init__( # noqa: PLR0913 fields will be used when converting the matcher to both an integration JSON object and 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. @@ -143,8 +133,6 @@ def __init__( # noqa: PLR0913 Generator used to generate a value when the value is not provided. """ - self._integration_fields: Mapping[str, Any] = integration_fields or {} - self._matching_rule_fields: Mapping[str, Any] = matching_rule_fields or {} self._extra_fields: Mapping[str, Any] = dict( chain((extra_fields or {}).items(), kwargs.items()) ) @@ -179,7 +167,6 @@ def to_integration_json(self) -> dict[str, Any]: else {} ), **self._extra_fields, - **self._integration_fields, } def to_matching_rule(self) -> dict[str, Any]: @@ -205,7 +192,6 @@ def to_matching_rule(self) -> dict[str, Any]: "match": self.type, **({"value": self.value} if not isinstance(self.value, Unset) else {}), **self._extra_fields, - **self._matching_rule_fields, }