diff --git a/.editorconfig b/.editorconfig index acd41cef19..e0c8b5cf06 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] diff --git a/examples/tests/v3/basic_flask_server.py b/examples/tests/v3/basic_flask_server.py new file mode 100644 index 0000000000..18b3c0c954 --- /dev/null +++ b/examples/tests/v3/basic_flask_server.py @@ -0,0 +1,147 @@ +""" +Simple flask server for matcher example. +""" + +import logging +import re +import signal +import subprocess +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 + +import requests +from yarl import URL + +from flask import Flask, Response, make_response + +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: + 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": random_regex_matches, + "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 + "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", + "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 + + @app.get("/_test/ping") + def ping() -> str: + """Simple ping endpoint for testing.""" + return "pong" + + app.run() diff --git a/examples/tests/v3/test_match.py b/examples/tests/v3/test_match.py new file mode 100644 index 0000000000..bb8dafeb97 --- /dev/null +++ b/examples/tests/v3/test_match.py @@ -0,0 +1,134 @@ +""" +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, generate, match + + +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", parameters={"providerStateArgument": "providerStateValue"}) + .with_request("GET", match.regex("/path/to/100", regex=r"/path/to/\d{1,4}")) + .with_query_parameter( + "asOf", + match.like( + [match.date("2024-01-01", format="%Y-%m-%d")], + min=1, + max=1, + ), + ) + .will_respond_with(200) + .with_body({ + "response": match.like( + { + "regexMatches": match.regex( + "must end with 'hello world'", + regex=r"^.*hello world'$", + ), + "randomRegexMatches": match.regex( + regex=r"1-8 digits: \d{1,8}, 1-8 random letters \w{1,8}" + ), + "integerMatches": match.int(42), + "decimalMatches": match.decimal(3.1415), + "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", + generator=generate.regex(r"\d{1,8} (hello )?world \d+"), + ), + "minMaxArrayMatches": match.each_like( + match.number(1.23, precision=2), + min=3, + max=5, + ), + "arrayContainingMatches": match.array_containing([ + match.int(1), + match.int(2), + ]), + "numbers": { + "intMatches": match.number(42), + "floatMatches": match.number(3.1415), + "intGeneratorMatches": match.number(2, max=10), + "decimalGeneratorMatches": match.number(3.1415, precision=4), + }, + "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( + "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)}, + rules=match.string("John Doe"), + ) + }, + rules=match.regex("id_1", regex=r"^id_\d+$"), + ), + }, + min=1, + ) + }) + .with_header( + "SpecialHeader", match.regex("Special: Foo", regex=r"^Special: \w+$") + ) + ) + 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 + 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"] 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}" + ) + assert random_regex_matcher.match( + response_data["response"]["randomRegexMatches"] + ) + random_integer = int(response_data["response"]["randomIntegerMatches"]) + assert random_integer >= 1 + assert random_integer <= 100 + float(response_data["response"]["randomDecimalMatches"]) + assert ( + len(response_data["response"]["randomDecimalMatches"].replace(".", "")) == 4 + ) + assert len(response_data["response"]["randomStringMatches"]) == 10 + + 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/generate/__init__.py b/src/pact/v3/generate/__init__.py new file mode 100644 index 0000000000..9c1f6d6c71 --- /dev/null +++ b/src/pact/v3/generate/__init__.py @@ -0,0 +1,377 @@ +""" +Generator module. +""" + +from __future__ import annotations + +import builtins +import warnings +from typing import TYPE_CHECKING, Literal, Mapping, Sequence + +from pact.v3.generate.generator import ( + 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__ = [ + "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. +__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 int( + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> Generator: + """ + Create a random integer generator. + + Args: + min: + The minimum value for the integer. + max: + The maximum value for the integer. + """ + 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 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: + precision: + The number of digits to generate. + """ + 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 hex(digits: builtins.int | None = None) -> Generator: + """ + Create a random hexadecimal generator. + + Args: + digits: + The number of digits to generate. + """ + 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 str(size: builtins.int | None = None) -> Generator: + """ + Create a random string generator. + + Args: + size: + The size of the string to generate. + """ + params: dict[builtins.str, builtins.int] = {} + if size is not None: + params["size"] = size + return GenericGenerator("RandomString", extra_fields=params) + + +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. + + The generator will generate a string that matches the given regex pattern. + + Args: + regex: + The regex pattern to match. + """ + return GenericGenerator("Regex", {"regex": regex}) + + +_UUID_FORMATS = { + "simple": "simple", + "lowercase": "lower-case-hyphenated", + "uppercase": "upper-case-hyphenated", + "urn": "URN", +} + + +def uuid( + format: Literal["simple", "lowercase", "uppercase", "urn"] = "lowercase", +) -> Generator: + """ + Create a UUID generator. + + Args: + format: + The format of the UUID to generate. This parameter is only supported + under the V4 specification. + """ + return GenericGenerator("Uuid", {"format": format}) + + +def date( + format: builtins.str = "%Y-%m-%d", + *, + disable_conversion: builtins.bool = False, +) -> Generator: + """ + Create a date generator. + + Args: + 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. + """ + 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: builtins.str = "%H:%M:%S", + *, + disable_conversion: builtins.bool = False, +) -> Generator: + """ + Create a time generator. + + Args: + 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. + """ + 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 datetime( + format: builtins.str, + *, + disable_conversion: builtins.bool = False, +) -> Generator: + """ + Create a date-time generator. + + Args: + 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 + """ + 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 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 GenericGenerator("RandomBoolean") + + +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. + + Generates a value that is looked up from the provider state context + using the given expression. + + Args: + expression: + The expression to use to look up the provider state. + """ + params: dict[builtins.str, builtins.str] = {} + if expression is not None: + params["expression"] = expression + return GenericGenerator("ProviderState", extra_fields=params) + + +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. + + Generates a URL with the mock server as the base URL. + + Args: + regex: + The regex pattern to match. + + example: + An example URL to use. + """ # 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) diff --git a/src/pact/v3/generate/generator.py b/src/pact/v3/generate/generator.py new file mode 100644 index 0000000000..633740be6b --- /dev/null +++ b/src/pact/v3/generate/generator.py @@ -0,0 +1,147 @@ +""" +Implementations of generators for the V3 and V4 specifications. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from itertools import chain +from typing import TYPE_CHECKING, Any, Mapping + +if TYPE_CHECKING: + from pact.v3.types import GeneratorType + + +class Generator(ABC): + """ + 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_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_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. + """ + + +class GenericGenerator(Generator): + """ + Generic generator. + + A generic generator, with the ability to specify the generator type and + additional configuration elements. + """ + + def __init__( + self, + type: GeneratorType, # noqa: A002 + /, + extra_fields: Mapping[str, Any] | None = None, + **kwargs: Any, # noqa: ANN401 + ) -> None: + """ + Instantiate the generator class. + + Args: + 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. + + **kwargs: + Alternative way to pass additional configuration elements to the + generator. See the `extra_fields` argument for more information. + """ + self.type = type + """ + The type of the generator. + """ + + self._extra_fields = dict(chain((extra_fields or {}).items(), kwargs.items())) + + 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. + """ + return { + "pact:generator:type": self.type, + **self._extra_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, + } diff --git a/src/pact/v3/interaction/_base.py b/src/pact/v3/interaction/_base.py index d597da59ff..d7756b1c88 100644 --- a/src/pact/v3/interaction/_base.py +++ b/src/pact/v3/interaction/_base.py @@ -16,10 +16,13 @@ from typing import TYPE_CHECKING, Any, Literal, overload import pact.v3.ffi +from pact.v3.match.matcher import IntegrationJSONEncoder if TYPE_CHECKING: from pathlib import Path + from pact.v3.match import Matcher + try: from typing import Self except ImportError: @@ -245,7 +248,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 +269,16 @@ 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 isinstance(body, str): + body_str = body + else: + body_str = json.dumps(body, cls=IntegrationJSONEncoder) + 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 66d70bcdf2..18e2f28239 100644 --- a/src/pact/v3/interaction/_http_interaction.py +++ b/src/pact/v3/interaction/_http_interaction.py @@ -4,11 +4,14 @@ from __future__ import annotations +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 +from pact.v3.match import Matcher +from pact.v3.match.matcher import IntegrationJSONEncoder if TYPE_CHECKING: try: @@ -94,7 +97,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 +109,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=IntegrationJSONEncoder) + 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 +215,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=IntegrationJSONEncoder) + else: + value_str = value pact.v3.ffi.with_header_v2( self._handle, interaction_part, name, index, - value, + value_str, ) return self @@ -342,7 +353,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. @@ -402,17 +413,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=IntegrationJSONEncoder) + 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, Any] | Iterable[tuple[str, Any]], ) -> Self: """ Add multiple query parameters to the request. diff --git a/src/pact/v3/match/__init__.py b/src/pact/v3/match/__init__.py new file mode 100644 index 0000000000..7220929895 --- /dev/null +++ b/src/pact/v3/match/__init__.py @@ -0,0 +1,872 @@ +""" +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. + +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 + +import builtins +import datetime as dt +import warnings +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Literal, Mapping, Sequence, TypeVar, overload + +from pact.v3 import generate +from pact.v3.match.matcher import ( + ArrayContainsMatcher, + 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: + from types import ModuleType + + from pact.v3.generate import Generator + +# 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__ = [ + "int", + "integer", + "float", + "decimal", + "number", + "str", + "string", + "regex", + "uuid", + "bool", + "boolean", + "date", + "time", + "datetime", + "timestamp", + "none", + "null", + "type", + "like", + "each_like", + "includes", + "array_containing", + "each_key_matches", + "each_value_matches", + "Matcher", +] + +_T = TypeVar("_T") + + +# 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: + 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. + """ + if value is UNSET: + return GenericMatcher( + "integer", + generator=generate.int(min=min, max=max), + ) + return GenericMatcher( + "integer", + value=value, + ) + + +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) + + +def float( + value: _NumberT | Unset = UNSET, + /, + *, + precision: builtins.int | None = None, +) -> Matcher[_NumberT]: + """ + Match a floating point number. + + Args: + value: + Default value to use when generating a consumer test. + precision: + The number of decimal precision to generate. + """ + if value is UNSET: + return GenericMatcher( + "decimal", + generator=generate.float(precision), + ) + return GenericMatcher( + "decimal", + value, + ) + + +def decimal( + value: _NumberT | Unset = UNSET, + /, + *, + precision: builtins.int | None = None, +) -> Matcher[_NumberT]: + """ + Alias for [`match.float`][pact.v3.match.float]. + """ + return float(value, precision=precision) + + +@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. + + 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. + + 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 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( + "The precision argument is ignored when value is an integer.", + stacklevel=2, + ) + return GenericMatcher( + "number", + value=value, + ) + + 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, + ) + + 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, + ) + + 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[builtins.str]: + """ + Match a string value. + + This function can be used to match a string value, merely verifying that the + value is a string, possibly with a specific length. + + Args: + value: + Default value to use when generating a consumer test. + size: + The size of the string to generate during a consumer test. + generator: + Alternative generator to use when generating a consumer test. If + set, the `size` argument is ignored. + """ + 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", + value="string", + generator=generator or generate.str(size), + ) + + if size is not None or generator: + warnings.warn( + "The size and generator arguments are ignored when a value is provided.", + stacklevel=2, + ) + return GenericMatcher( + "type", + value=value, + ) + + +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, + /, + *, + regex: builtins.str | None = None, +) -> Matcher[builtins.str]: + """ + Match a string against a regular expression. + + Args: + value: + Default value to use when generating a consumer test. + regex: + The regular expression to match against. + """ + 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, + regex=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']})$" + ) + if value is UNSET: + return GenericMatcher( + "regex", + generator=generate.uuid(format or "lowercase"), + regex=pattern, + ) + return GenericMatcher( + "regex", + value=value, + regex=pattern, + ) + + +def bool(value: builtins.bool | Unset = UNSET, /) -> Matcher[builtins.bool]: + """ + Match a boolean value. + + Args: + value: + Default value to use when generating a consumer test. + """ + if value is UNSET: + return GenericMatcher("boolean", generator=generate.bool()) + return GenericMatcher("boolean", value) + + +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, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> Matcher[builtins.str]: + """ + Match a date value. + + A date value is a string that represents a date in a specific format. It + does _not_ have any time information. + + Args: + value: + 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: `%Y-%m-%d`. + disable_conversion: + If True, the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format will be disabled, and the format must be + in Java's `SimpleDateFormat` format. As a result, the value must + be a string as Python cannot format the date in the target format. + """ + if disable_conversion: + 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, + ) + + 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, + ) + + +def time( + value: dt.time | builtins.str | Unset = UNSET, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> Matcher[builtins.str]: + """ + Match a time value. + + A time value is a string that represents a time in a specific format. It + does _not_ have any date information. + + Args: + value: + 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: `%H:%M:%S`. + disable_conversion: + If True, the conversion from Python's `strftime` format to Java's + `SimpleDateFormat` format will be disabled, and the format must be + in Java's `SimpleDateFormat` format. As a result, the value must + be a string as Python cannot format the time in the target format. + """ + if disable_conversion: + 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, + ) + 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, + ) + + +def datetime( + value: dt.datetime | builtins.str | Unset = UNSET, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> Matcher[builtins.str]: + """ + Match a datetime 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: + `%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. + """ + if disable_conversion: + 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, + ) + 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, + ) + + +def timestamp( + value: dt.datetime | builtins.str | Unset = UNSET, + /, + format: builtins.str | None = None, + *, + disable_conversion: builtins.bool = False, +) -> Matcher[builtins.str]: + """ + Alias for [`match.datetime`][pact.v3.match.datetime]. + """ + return datetime(value, format, disable_conversion=disable_conversion) + + +def none() -> Matcher[None]: + """ + Match a null value. + """ + return GenericMatcher("null") + + +def null() -> Matcher[None]: + """ + Alias for [`match.none`][pact.v3.match.none]. + """ + return none() + + +def type( + value: _T, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, + generator: Generator | None = None, +) -> Matcher[_T]: + """ + Match a value by type. + + Args: + value: + 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. + """ + 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) + + +def like( + value: _T, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, + generator: Generator | None = None, +) -> Matcher[_T]: + """ + Alias for [`match.type`][pact.v3.match.type]. + """ + return type(value, min=min, max=max, generator=generator) + + +def each_like( + value: _T, + /, + *, + min: builtins.int | None = None, + max: builtins.int | None = None, +) -> Matcher[Sequence[_T]]: # type: ignore[type-var] + """ + Match each item in an array against a given value. + + The value itself is arbitrary, and can include other matchers. + + Args: + value: + The value to match against. + 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. + """ + 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) # type: ignore[return-value] + + +def includes( + value: builtins.str, + /, + *, + generator: Generator | None = None, +) -> Matcher[builtins.str]: + """ + Match a string that includes a given value. + + Args: + value: + The value to match against. + generator: + The generator to use when generating the value. + """ + return GenericMatcher( + "include", + value=value, + generator=generator, + ) + + +def array_containing(variants: Sequence[_T | Matcher[_T]], /) -> Matcher[Sequence[_T]]: + """ + 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. + + Args: + variants: + A list of variants to match against. + """ + return ArrayContainsMatcher(variants=variants) + + +def each_key_matches( + value: Mapping[_T, Any], + /, + *, + rules: Matcher[_T] | list[Matcher[_T]], +) -> Matcher[Mapping[_T, Matchable]]: + """ + Match 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 EachKeyMatcher(value=value, rules=rules) + + +def each_value_matches( + value: Mapping[Any, _T], + /, + *, + 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. + + Args: + value: + The value to match against. + rules: + The matching rules to match against each value. + """ + if isinstance(rules, Matcher): + rules = [rules] + return EachValueMatcher(value=value, rules=rules) diff --git a/src/pact/v3/match/matcher.py b/src/pact/v3/match/matcher.py new file mode 100644 index 0000000000..1a24991919 --- /dev/null +++ b/src/pact/v3/match/matcher.py @@ -0,0 +1,326 @@ +""" +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 collections.abc import Mapping +from itertools import chain +from json import JSONEncoder +from typing import Any, Generic, Sequence, TypeVar + +from pact.v3.generate.generator import Generator +from pact.v3.types import UNSET, Matchable, MatcherType, Unset + +_T = TypeVar("_T") + + +class Matcher(ABC, Generic[_T]): + """ + 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 + communicating with the FFI and 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[_T]): + """ + Generic matcher. + + A generic matcher, with the ability to define arbitrary additional fields + for inclusion in the integration JSON object and matching rule. + """ + + def __init__( + self, + type: MatcherType, # noqa: A002 + /, + value: _T | Unset = UNSET, + generator: Generator | None = None, + extra_fields: Mapping[str, Any] | 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 both an + integration JSON object and 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: _T | 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._extra_fields: Mapping[str, Any] = 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 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: + 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_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._extra_fields, + } + + +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 EachKeyMatcher(Matcher[Mapping[_T, Matchable]]): + """ + Each key matcher. + + A matcher that applies a matcher to each key in a mapping. + """ + + def __init__( + self, + value: Mapping[_T, Matchable], + 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", + value=value, + extra_fields={"rules": rules}, + ) + + 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: Mapping[Matchable, _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", + value=value, + extra_fields={"rules": rules}, + ) + + 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. + + 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() + if isinstance(o, Generator): + return o.to_integration_json() + return super().default(o) diff --git a/src/pact/v3/types.py b/src/pact/v3/types.py new file mode 100644 index 0000000000..d4bd0e6a08 --- /dev/null +++ b/src/pact/v3/types.py @@ -0,0 +1,46 @@ +""" +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 + +from typing_extensions import TypeAlias + +Matchable: TypeAlias = Any +""" +All supported matchable types. +""" + +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/types.pyi b/src/pact/v3/types.pyi new file mode 100644 index 0000000000..e29d6bd41f --- /dev/null +++ b/src/pact/v3/types.pyi @@ -0,0 +1,121 @@ +# 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 Literal + +from pydantic import BaseModel +from typing_extensions import TypeAlias + +_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[Matchable, 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. +""" + +_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 | _StdlibMatchable | _ExtraMatchable +) +""" +All supported matchable types. +""" + +_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() diff --git a/src/pact/v3/util.py b/src/pact/v3/util.py new file mode 100644 index 0000000000..64a1783135 --- /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_http_interaction.py b/tests/v3/test_http_interaction.py index b235cfbb22..b700fddd78 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, match 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", match.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"], diff --git a/tests/v3/test_util.py b/tests/v3/test_util.py new file mode 100644 index 0000000000..65f915f835 --- /dev/null +++ b/tests/v3/test_util.py @@ -0,0 +1,39 @@ +""" +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" + 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: + 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"