From 812bde53a6442d4e0c344decfb2bce6e7d673783 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 28 Mar 2025 13:06:45 +1100 Subject: [PATCH 1/8] chore: add apply_arg utility Instead of doing basic checks on the signature of functional argument, create a utility which will be responsible for passing arguments through to the functional argument. Signed-off-by: JP-Ellis --- src/pact/v3/_util.py | 110 ++++++++++++++++++++++++++++ tests/v3/test_util.py | 167 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 276 insertions(+), 1 deletion(-) diff --git a/src/pact/v3/_util.py b/src/pact/v3/_util.py index 49bc3e791..cf9099ebe 100644 --- a/src/pact/v3/_util.py +++ b/src/pact/v3/_util.py @@ -7,9 +7,17 @@ notice. """ +import inspect +import logging import socket import warnings +from collections.abc import Callable, Mapping from contextlib import closing +from functools import partial +from inspect import Parameter, _ParameterKind +from typing import TypeVar + +logger = logging.getLogger(__name__) _PYTHON_FORMAT_TO_JAVA_DATETIME = { "a": "EEE", @@ -42,6 +50,8 @@ ":z": "XXX", } +_T = TypeVar("_T") + def strftime_to_simple_date_format(python_format: str) -> str: """ @@ -159,3 +169,103 @@ def find_free_port() -> int: s.bind(("", 0)) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) return s.getsockname()[1] + + +def apply_args(f: Callable[..., _T], args: Mapping[str, object]) -> _T: + """ + Apply arguments to a function. + + This function passes through the arguments to the function, doing so + intelligently by performing runtime introspection to determine whether + it is possible to pass arguments by name, and falling back to positional + arguments if not. + + Args: + f: + The function to apply the arguments to. + + args: + The arguments to apply. The dictionary is ordered such that, if an + argument cannot be passed by name, it will be passed by position + as per the order of the keys in the dictionary. + + Returns: + The result of the function. + """ + signature = inspect.signature(f) + f_name = ( + f.__qualname__ + if hasattr(f, "__qualname__") + else f"{type(f).__module__}.{type(f).__name__}" + ) + args = dict(args) + + # If the signature has a `*args` parameter, then parameters which appear as + # positional-or-keyword must be passed as positional arguments. + if any( + param.kind == Parameter.VAR_POSITIONAL + for param in signature.parameters.values() + ): + positional_match: list[_ParameterKind] = [ + Parameter.POSITIONAL_ONLY, + Parameter.POSITIONAL_OR_KEYWORD, + ] + keyword_match: list[_ParameterKind] = [Parameter.KEYWORD_ONLY] + else: + positional_match = [Parameter.POSITIONAL_ONLY] + keyword_match = [Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY] + + # First, we inspect the keyword arguments and try and pass in some arguments + # by currying them in. + for param in signature.parameters.values(): + if param.name not in args: + # If a parameter is not known, we will ignore it. + # + # If the ignored parameter doesn't have a default value, it will + # result in a exception, but we will also warn the user here. + if param.default == Parameter.empty and param.kind not in [ + Parameter.VAR_POSITIONAL, + Parameter.VAR_KEYWORD, + ]: + msg = ( + f"Function {f_name} appears to have required " + f"parameter '{param.name}' that will not be passed" + ) + warnings.warn(msg, stacklevel=2) + + continue + + if param.kind in positional_match: + # We iterate through the parameters in order that they are defined, + # making it fine to pass them in by position one at a time. + f = partial(f, args[param.name]) + del args[param.name] + + if param.kind in keyword_match: + f = partial(f, **{param.name: args[param.name]}) + del args[param.name] + continue + + # At this stage, we have checked all arguments. If we have any arguments + # remaining, we will try and pass them through variadic arguments if the + # function accepts them. + if args: + if Parameter.VAR_KEYWORD in [ + param.kind for param in signature.parameters.values() + ]: + f = partial(f, **args) + args.clear() + elif Parameter.VAR_POSITIONAL in [ + param.kind for param in signature.parameters.values() + ]: + f = partial(f, *args.values()) + args.clear() + else: + logger.debug( + "Function %s does not accept any additional arguments. " + "remaining arguments: %s", + f_name, + list(args.keys()), + ) + + return f() diff --git a/tests/v3/test_util.py b/tests/v3/test_util.py index 19f2356db..14d900e56 100644 --- a/tests/v3/test_util.py +++ b/tests/v3/test_util.py @@ -2,9 +2,16 @@ Tests of pact.v3.util functions. """ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + import pytest -from pact.v3._util import strftime_to_simple_date_format +from pact.v3._util import apply_args, strftime_to_simple_date_format + +if TYPE_CHECKING: + from collections.abc import Callable def test_convert_python_to_java_datetime_format_basic() -> None: @@ -37,3 +44,161 @@ def test_convert_python_to_java_datetime_format_with_escape_characters() -> None def test_convert_python_to_java_datetime_format_with_single_quote() -> None: assert strftime_to_simple_date_format("%Y'%m'%d") == "yyyy''MM''dd" + + +def no_annotations(a, b, c, d=b"d"): # noqa: ANN001, ANN201 # type: ignore[reportUnknownArgumentType] + return f"{a}:{b}:{c}:{d!r}" + + +def annotated(a: int, b: str, c: float, d: bytes = b"d") -> str: + return f"{a}:{b}:{c}:{d!r}" + + +def mixed(a: int, /, b: str, *, c: float, d: bytes = b"d") -> str: + return f"{a}:{b}:{c}:{d!r}" + + +def variadic_args(*args: Any) -> str: # noqa: ANN401 + return ":".join(str(arg) for arg in args) + + +def variadic_kwargs(**kwargs: Any) -> str: # noqa: ANN401 + return ":".join(str(v) for v in kwargs.values()) + + +def variadic_args_kwargs(*args: Any, **kwargs: Any) -> list[str]: # noqa: ANN401 + return [ + ":".join(str(arg) for arg in args), + ":".join(str(v) for v in kwargs.values()), + ] + + +def mixed_variadic_args(a: int, *args: Any, d: bytes = b"d") -> list[str]: # noqa: ANN401 + return [f"{a}:{d!r}", ":".join(str(arg) for arg in args)] + + +def mixed_variadic_kwargs(a: int, d: bytes = b"d", **kwargs: Any) -> list[str]: # noqa: ANN401 + return [f"{a}:{d!r}", ":".join(str(v) for v in kwargs.values())] + + +def mixed_variadic_args_kwargs( + a: int, + *args: Any, # noqa: ANN401 + d: bytes = b"d", + **kwargs: Any, # noqa: ANN401 +) -> list[str]: + return [ + f"{a}:{d!r}", + ":".join(str(arg) for arg in args), + ":".join(str(v) for v in kwargs.values()), + ] + + +class Foo: # noqa: D101 + def __init__(self) -> None: # noqa: D107 + pass + + def __call__(self, a: int, b: str, c: float, d: bytes = b"d") -> str: # noqa: D102 + return f"{a}:{b}:{c}:{d!r}" + + def method(self, a: int, b: str, c: float, d: bytes = b"d") -> str: # noqa: D102 + return f"{a}:{b}:{c}:{d!r}" + + @classmethod + def class_method(cls, a: int, b: str, c: float, d: bytes = b"d") -> str: # noqa: D102 + return f"{a}:{b}:{c}:{d!r}" + + @staticmethod + def static_method(a: int, b: str, c: float, d: bytes = b"d") -> str: # noqa: D102 + return f"{a}:{b}:{c}:{d!r}" + + +@pytest.mark.parametrize( + ("func", "args", "expected"), + [ + (no_annotations, {"a": 1, "b": "b", "c": 3.14}, "1:b:3.14:b'd'"), + (no_annotations, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, "1:b:3.14:b'e'"), + (annotated, {"a": 1, "b": "b", "c": 3.14}, "1:b:3.14:b'd'"), + (annotated, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, "1:b:3.14:b'e'"), + (mixed, {"a": 1, "b": "b", "c": 3.14}, "1:b:3.14:b'd'"), + (mixed, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, "1:b:3.14:b'e'"), + (variadic_args, {"a": 1, "b": "b", "c": 3.14}, "1:b:3.14"), + (variadic_args, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, "1:b:3.14:b'e'"), + (variadic_kwargs, {"a": 1, "b": "b", "c": 3.14}, "1:b:3.14"), + (variadic_kwargs, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, "1:b:3.14:b'e'"), + (variadic_args_kwargs, {"a": 1, "b": "b", "c": 3.14}, ["", "1:b:3.14"]), + ( + variadic_args_kwargs, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + ["", "1:b:3.14:b'e'"], + ), + (mixed_variadic_args, {"a": 1, "b": "b", "c": 3.14}, ["1:b'd'", "b:3.14"]), + ( + mixed_variadic_args, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + ["1:b'e'", "b:3.14"], + ), + (mixed_variadic_kwargs, {"a": 1, "b": "b", "c": 3.14}, ["1:b'd'", "b:3.14"]), + ( + mixed_variadic_kwargs, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + ["1:b'e'", "b:3.14"], + ), + ( + mixed_variadic_args_kwargs, + {"a": 1, "b": "b", "c": 3.14}, + ["1:b'd'", "", "b:3.14"], + ), + ( + mixed_variadic_args_kwargs, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + ["1:b'e'", "", "b:3.14"], + ), + ( + mixed_variadic_args_kwargs, + {"a": 1, "b": "b", "c": 3.14, "e": "f"}, + ["1:b'd'", "", "b:3.14:f"], + ), + ( + mixed_variadic_args_kwargs, + {"a": 1, "b": "b", "c": 3.14, "e": "f", "d": b"e"}, + ["1:b'e'", "", "b:3.14:f"], + ), + ( + Foo(), + {"a": 1, "b": "b", "c": 3.14}, + "1:b:3.14:b'd'", + ), + ( + Foo(), + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + "1:b:3.14:b'e'", + ), + ( + Foo().class_method, + {"a": 1, "b": "b", "c": 3.14}, + "1:b:3.14:b'd'", + ), + ( + Foo().class_method, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + "1:b:3.14:b'e'", + ), + ( + Foo().static_method, + {"a": 1, "b": "b", "c": 3.14}, + "1:b:3.14:b'd'", + ), + ( + Foo().static_method, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + "1:b:3.14:b'e'", + ), + ], # type: ignore[reportUnknownArgumentType] +) +def test_apply_expected( + func: Callable[..., Any], + args: dict[str, Any], + expected: str | list[str], +) -> None: + assert apply_args(func, args) == expected From 5c0c16d0bc8b87de008e8d156e6cfe485b759457 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 28 Mar 2025 13:13:19 +1100 Subject: [PATCH 2/8] feat(v3): allow more flexible functional arguments The first support for functional arguments was very strict and did not allow mixtures of positional and keyword arguments, and also did not allow functions to have optional arguments. This commit introduces a rewrite of the way arguments are applied. The benefit is complete flexibility, with position, keyword and variadic arguments all being supported. In order to support this though, function _must_ have argument names that form a subset of the `MessageProducerArgs` and `StateHandlerArgs` typed dictionaries. BREAKING CHANGE: The signature of functional arguments must form a subset of the `MessageProducerArgs` and `StateHandlerArgs` typed dictionaries. Signed-off-by: JP-Ellis --- src/pact/v3/_server.py | 24 +- src/pact/v3/types.py | 116 ++++---- src/pact/v3/types.pyi | 75 +---- src/pact/v3/verifier.py | 265 +++++++----------- tests/v3/compatibility_suite/util/provider.py | 18 +- 5 files changed, 192 insertions(+), 306 deletions(-) diff --git a/src/pact/v3/_server.py b/src/pact/v3/_server.py index 5b1fb32c0..19a1e6172 100644 --- a/src/pact/v3/_server.py +++ b/src/pact/v3/_server.py @@ -35,16 +35,18 @@ from pact import __version__ from pact.v3._util import find_free_port +from pact.v3.types import Message if TYPE_CHECKING: from types import TracebackType - from pact.v3.types import MessageProducerFull, StateHandlerFull logger = logging.getLogger(__name__) _C = TypeVar("_C", bound=Callable[..., Any]) +_CM = TypeVar("_CM", bound=Callable[..., Message]) +_CN = TypeVar("_CN", bound=Callable[..., None]) class HandlerHttpServer(ThreadingHTTPServer, Generic[_C]): @@ -88,7 +90,7 @@ def __init__( ################################################################################ -class MessageProducer: +class MessageProducer(Generic[_CM]): """ Internal message producer server. @@ -102,7 +104,7 @@ class MessageProducer: def __init__( self, - handler: MessageProducerFull, + handler: _CM, host: str = "localhost", port: int | None = None, ) -> None: @@ -134,7 +136,7 @@ def __init__( self._handler = handler - self._server: HandlerHttpServer[MessageProducerFull] | None = None + self._server: HandlerHttpServer[_CM] | None = None self._thread: Thread | None = None @property @@ -204,7 +206,7 @@ def __exit__( self._thread.join() -class MessageProducerHandler(SimpleHTTPRequestHandler): +class MessageProducerHandler(SimpleHTTPRequestHandler, Generic[_CM]): """ Request handler for the message relay server. @@ -227,7 +229,7 @@ class MessageProducerHandler(SimpleHTTPRequestHandler): """ if TYPE_CHECKING: - server: HandlerHttpServer[MessageProducerFull] + server: HandlerHttpServer[_CM] MESSAGE_PATH = "/_pact/message" @@ -306,7 +308,7 @@ def do_GET(self) -> None: # noqa: N802 ################################################################################ -class StateCallback: +class StateCallback(Generic[_CN]): """ Internal server for handling state callbacks. @@ -317,7 +319,7 @@ class StateCallback: def __init__( self, - handler: StateHandlerFull, + handler: _CN, host: str = "localhost", port: int | None = None, ) -> None: @@ -343,7 +345,7 @@ def __init__( self._handler = handler - self._server: HandlerHttpServer[StateHandlerFull] | None = None + self._server: HandlerHttpServer[_CN] | None = None self._thread: Thread | None = None @property @@ -406,7 +408,7 @@ def __exit__( self._thread.join() -class StateCallbackHandler(SimpleHTTPRequestHandler): +class StateCallbackHandler(SimpleHTTPRequestHandler, Generic[_CN]): """ Request handler for the state callback server. @@ -415,7 +417,7 @@ class StateCallbackHandler(SimpleHTTPRequestHandler): """ if TYPE_CHECKING: - server: HandlerHttpServer[StateHandlerFull] + server: HandlerHttpServer[_CN] CALLBACK_PATH = "/_pact/state" diff --git a/src/pact/v3/types.py b/src/pact/v3/types.py index 7a40062d0..a0fd4eff3 100644 --- a/src/pact/v3/types.py +++ b/src/pact/v3/types.py @@ -8,8 +8,7 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any, Optional, TypedDict, Union +from typing import Any, Literal, TypedDict, Union from typing_extensions import TypeAlias from yarl import URL @@ -57,74 +56,83 @@ class Message(TypedDict): """ -MessageProducerFull: TypeAlias = Callable[[str, Optional[dict[str, Any]]], Message] -""" -Full message producer signature. - -This is the signature for a message producer that takes two arguments: - -1. The message name, as a string. -2. A dictionary of parameters, or `None` if no parameters are provided. - -The function must return a `bytes` object. -""" +class MessageProducerArgs(TypedDict, total=False): + """ + Arguments for the message handler functions. + + The message producer function must be able to accept these arguments. Pact + Python will inspect the function's type signature to determine how best to + pass the arguments in (e.g., as keyword arguments, position arguments, + variadic arguments, or a combination of these). Note that Pact Python will + prefer the use of keyword arguments if available, and therefore it is + recommended to allow keyword arguments for the fields below if possible. + """ -MessageProducerNoName: TypeAlias = Callable[[Optional[dict[str, Any]]], Message] -""" -Message producer signature without the name. + name: str + """ + The name of the message. -This is the signature for a message producer that takes one argument: + This is used to identify the message so that the function knows which + message to generate. This is typically a string that describes the + message. For example, `"a request to create a new user"` or `"a metric event + for a user login"`. -1. A dictionary of parameters, or `None` if no parameters are provided. + This may be omitted if the message producer functions are passed through a + dictionary where the key is used to identify the message. + """ -The function must return a `bytes` object. + metadata: dict[str, Any] | None + """ + Metadata associated with the message. + """ -This function must be provided as part of a dictionary mapping message names to -functions. -""" -StateHandlerFull: TypeAlias = Callable[[str, str, Optional[dict[str, Any]]], None] -""" -Full state handler signature. +class StateHandlerArgs(TypedDict, total=False): + """ + Arguments for the state handler functions. + + The state handler function must be able to accept these arguments. Pact + Python will inspect the function's type signature to determine how best to + pass the arguments in (e.g., as keyword arguments, position arguments, + variadic arguments, or a combination of these). Note that Pact Python will + prefer the use of keyword arguments if available, and therefore it is + recommended to allow keyword arguments for the fields below if possible. + """ -This is the signature for a state handler that takes three arguments: + state: str + """ + The name of the state. -1. The state name, as a string. -2. The action (either `setup` or `teardown`), as a string. -3. A dictionary of parameters, or `None` if no parameters are provided. -""" -StateHandlerNoAction: TypeAlias = Callable[[str, Optional[dict[str, Any]]], None] -""" -State handler signature without the action. + This is used to identify the state so that the function knows which state to + generate. This is typically a string that describes the state. For example, + `"user exists"`. -This is the signature for a state handler that takes two arguments: + If the function is passed through a dictionary where the key is used to + identify the state, this argument is not required. + """ -1. The state name, as a string. -2. A dictionary of parameters, or `None` if no parameters are provided. -""" -StateHandlerNoState: TypeAlias = Callable[[str, Optional[dict[str, Any]]], None] -""" -State handler signature without the state. + action: Literal["setup", "teardown"] + """ + The action to perform. -This is the signature for a state handler that takes two arguments: + This is either `"setup"` or `"teardown"`, and indicates whether the state + should be set up or torn down. -1. The action (either `setup` or `teardown`), as a string. -2. A dictionary of parameters, or `None` if no parameters are provided. + This argument is only used if the state handler is expected to perform both + setup and teardown actions (i.e., if `teardown=True` is used when calling + [`Verifier.state_handler][pact.v3.verifier.Verifier.state_handler]`). + """ -This function must be provided as part of a dictionary mapping state names to -functions. -""" -StateHandlerNoActionNoState: TypeAlias = Callable[[Optional[dict[str, Any]]], None] -""" -State handler signature without the state or action. + parameters: dict[str, Any] | None + """ + Parameters required to generate the state. -This is the signature for a state handler that takes one argument: + This can be used to pass in any additional parameters that are required to + generate the state. For example, if the state requires a user ID, this can + be passed in here. + """ -1. A dictionary of parameters, or `None` if no parameters are provided. -This function must be provided as part of a dictionary mapping state names to -functions. -""" StateHandlerUrl: TypeAlias = Union[str, URL] """ State handler URL signature. diff --git a/src/pact/v3/types.pyi b/src/pact/v3/types.pyi index 9c9f66264..f5e1ae880 100644 --- a/src/pact/v3/types.pyi +++ b/src/pact/v3/types.pyi @@ -4,7 +4,7 @@ # As a result, it is safe to perform expensive imports, even if they are not # used or available at runtime. -from collections.abc import Callable, Collection, Mapping, Sequence +from collections.abc import Collection, Mapping, Sequence from collections.abc import Set as AbstractSet from datetime import date, datetime, time from decimal import Decimal @@ -122,74 +122,15 @@ class Message(TypedDict): metadata: dict[str, Any] | None content_type: str | None -MessageProducerFull: TypeAlias = Callable[[str, dict[str, Any] | None], Message] -""" -Full message producer signature. - -This is the signature for a message producer that takes two arguments: - -1. The message name, as a string. -2. A dictionary of parameters, or `None` if no parameters are provided. - -The function must return a `bytes` object. -""" - -MessageProducerNoName: TypeAlias = Callable[[dict[str, Any] | None], Message] -""" -Message producer signature without the name. - -This is the signature for a message producer that takes one argument: - -1. A dictionary of parameters, or `None` if no parameters are provided. - -The function must return a `bytes` object. - -This function must be provided as part of a dictionary mapping message names to -functions. -""" - -StateHandlerFull: TypeAlias = Callable[[str, str, dict[str, Any] | None], None] -""" -Full state handler signature. - -This is the signature for a state handler that takes three arguments: - -1. The state name, as a string. -2. The action (either `setup` or `teardown`), as a string. -3. A dictionary of parameters, or `None` if no parameters are provided. -""" -StateHandlerNoAction: TypeAlias = Callable[[str, dict[str, Any] | None], None] -""" -State handler signature without the action. - -This is the signature for a state handler that takes two arguments: - -1. The state name, as a string. -2. A dictionary of parameters, or `None` if no parameters are provided. -""" -StateHandlerNoState: TypeAlias = Callable[[str, dict[str, Any] | None], None] -""" -State handler signature without the state. - -This is the signature for a state handler that takes two arguments: - -1. The action (either `setup` or `teardown`), as a string. -2. A dictionary of parameters, or `None` if no parameters are provided. - -This function must be provided as part of a dictionary mapping state names to -functions. -""" -StateHandlerNoActionNoState: TypeAlias = Callable[[dict[str, Any] | None], None] -""" -State handler signature without the state or action. - -This is the signature for a state handler that takes one argument: +class MessageProducerArgs(TypedDict, total=False): + name: str + metadata: dict[str, Any] | None -1. A dictionary of parameters, or `None` if no parameters are provided. +class StateHandlerArgs(TypedDict, total=False): + state: str + action: Literal["setup", "teardown"] + parameters: dict[str, Any] | None -This function must be provided as part of a dictionary mapping state names to -functions. -""" StateHandlerUrl: TypeAlias = str | URL """ State handler URL signature. diff --git a/src/pact/v3/verifier.py b/src/pact/v3/verifier.py index 1e893277e..185aac767 100644 --- a/src/pact/v3/verifier.py +++ b/src/pact/v3/verifier.py @@ -73,10 +73,9 @@ from __future__ import annotations -import inspect import json import logging -import typing +from collections.abc import Mapping from contextlib import nullcontext from datetime import date from pathlib import Path @@ -87,20 +86,13 @@ import pact.v3.ffi from pact.v3._server import MessageProducer, StateCallback +from pact.v3._util import apply_args +from pact.v3.types import Message, MessageProducerArgs, StateHandlerArgs if TYPE_CHECKING: from collections.abc import Iterable - from pact.v3.types import ( - Message, - MessageProducerFull, - MessageProducerNoName, - StateHandlerFull, - StateHandlerNoAction, - StateHandlerNoActionNoState, - StateHandlerNoState, - StateHandlerUrl, - ) + from pact.v3.types import StateHandlerUrl logger = logging.getLogger(__name__) @@ -180,8 +172,12 @@ def __init__(self, name: str, host: str | None = None) -> None: # transport methods defined, and then before verification call the # `set_info` and `add_transport` FFI methods as needed. self._transports: list[_ProviderTransport] = [] - self._message_producer: MessageProducer | nullcontext[None] = nullcontext() - self._state_handler: StateCallback | nullcontext[None] = nullcontext() + self._message_producer: ( + MessageProducer[Callable[..., Message]] | nullcontext[None] + ) = nullcontext() + self._state_handler: StateCallback[Callable[..., None]] | nullcontext[None] = ( + nullcontext() + ) self._disable_ssl_verification = False self._request_timeout = 5000 # Using a broker source requires knowing the provider name, which is @@ -323,17 +319,10 @@ def add_transport( return self - @overload - def message_handler(self, handler: MessageProducerFull) -> Self: ... - @overload - def message_handler( - self, - handler: dict[str, MessageProducerNoName | Message], - ) -> Self: ... - def message_handler( self, - handler: MessageProducerFull | dict[str, MessageProducerNoName | Message], + handler: Callable[..., Message] + | dict[str, Callable[..., Message] | Message | bytes], ) -> Self: """ Set the message handler. @@ -343,20 +332,15 @@ def message_handler( This can be provided in one of two ways: - 1. A fully fledged function that will be called for all messages. The - function must take two arguments: the name of the message (as a - string), and optional parameters (as a dictionary). This then - returns the message as bytes. - - This is the most powerful option as it allows for full control over - the message generation. + 1. A fully fledged function that will be called for all messages. This + is the most powerful option as it allows for full control over the + message generation. The function's signature must be compatible with + the [`MessageProducerArgs`][pact.v3.types.MessageProducerArgs] type. - 2. A dictionary mapping message names to producer functions, or bytes. - In this case, the producer function must take optional parameters - (as a dictionary) and return the message as bytes. - - If the message to be produced is static, the bytes can be provided - directly. + 2. A dictionary mapping message names to either (a) producer functions, + (b) [`Message`][pact.v3.types.Message] dictionaries, or (c) raw + bytes. If using a producer function, it must be compatible with the + [`MessageProducerArgs`][pact.v3.types.MessageProducerArgs] type. ## Implementation @@ -384,34 +368,50 @@ def message_handler( ) if callable(handler): - if len(inspect.signature(handler).parameters) != 2: # noqa: PLR2004 - msg = "The function must take two arguments: name and parameters" - raise TypeError(msg) - self._message_producer = MessageProducer(handler) + def _handler( + name: str, + metadata: dict[str, Any] | None, + ) -> Message: + logger.info("Internal message produced called.") + return apply_args( + handler, + MessageProducerArgs(name=name, metadata=metadata), + ) + + self._message_producer = MessageProducer(_handler) self.add_transport( protocol="message", port=self._message_producer.port, path=self._message_producer.path, ) + return self if isinstance(handler, dict): - # Check that all values are either callable with one argument, or - # bytes. - for value in handler.values(): - if callable(value) and len(inspect.signature(value).parameters) != 1: - msg = "All functions must take one argument: parameters" - raise TypeError(msg) - if not callable(value) and not isinstance(value, dict): - msg = "All values must be callable or dictionaries" - raise TypeError(msg) - - def _handler(name: str, parameters: dict[str, Any] | None) -> Message: - logger.info("Internal handler called") + + def _handler( + name: str, + metadata: dict[str, Any] | None, + ) -> Message: + logger.info("Internal message produced called.") val = handler[name] + if callable(val): - return val(parameters) - return val + return apply_args( + val, + MessageProducerArgs(name=name, metadata=metadata), + ) + if isinstance(val, bytes): + return Message(contents=val, metadata=None, content_type=None) + if isinstance(val, dict): + return Message( + contents=val["contents"], + metadata=val.get("metadata"), + content_type=val.get("content_type"), + ) + + msg = "Invalid message handler value" + raise TypeError(msg) self._message_producer = MessageProducer(_handler) self.add_transport( @@ -420,7 +420,10 @@ def _handler(name: str, parameters: dict[str, Any] | None) -> Message: path=self._message_producer.path, ) - return self + return self + + msg = "Invalid message handler type" + raise TypeError(msg) def filter( self, @@ -465,41 +468,25 @@ def filter( ) return self - # Cases where the handler takes the state name. - @overload - def state_handler( - self, - handler: StateHandlerFull, - *, - teardown: Literal[True], - body: None = None, - ) -> Self: ... - @overload - def state_handler( - self, - handler: StateHandlerNoAction, - *, - teardown: Literal[False] = False, - body: None = None, - ) -> Self: ... - # Cases where the handler takes a dictionary of functions + # Functional argument, either direct or via a dictionary. @overload def state_handler( self, - handler: dict[str, StateHandlerNoState], + handler: Callable[..., None], *, - teardown: Literal[True], + teardown: bool = False, body: None = None, ) -> Self: ... @overload def state_handler( self, - handler: dict[str, StateHandlerNoActionNoState], + handler: Mapping[str, Callable[..., None]], *, - teardown: Literal[False] = False, + teardown: bool = False, body: None = None, ) -> Self: ... - # Cases where the handler takes a URL + # Cases where the handler takes a URL. The `body` argument is required in + # this case. @overload def state_handler( self, @@ -511,10 +498,8 @@ def state_handler( def state_handler( self, - handler: StateHandlerFull - | StateHandlerNoAction - | dict[str, StateHandlerNoState] - | dict[str, StateHandlerNoActionNoState] + handler: Callable[..., None] + | Mapping[str, Callable[..., None]] | StateHandlerUrl, *, teardown: bool = False, @@ -538,25 +523,15 @@ def state_handler( 2. By providing a mapping of state names to functions. 3. By providing the URL endpoint to which the request should be made. - The first two options are most straightforward to use. - - When providing a function, the arguments should be: - - 1. The state name, as a string. - 2. The action (either `setup` or `teardown`), as a string. - 3. A dictionary of parameters, or `None` if no parameters are provided. + The last option is more complicated as it requires the provider to be + able to handle the state change requests. The first two options handle + this internally and are the preferred options if the provider is written + in Python. - Note that these arguments will change in the following ways: - - 1. If a dictionary mapping is used, the state name is _not_ provided to - the function. - 2. If `teardown` is `False` thereby indicating that the function is - only called for setup, the `action` argument is not provided. - - This means that in the case of a dictionary mapping of function with - `teardown=False`, the function should take only one argument: the - dictionary of parameters (which itself may be `None`, albeit still an - argument). + The function signature must be compatible with the + [`StateHandlerArgs`][pact.v3.types.StateHandlerArgs]. If the function + has additional arguments, these must either have default values, or be + filled by using the [`partial`][functools.partial] function. Args: handler: @@ -576,8 +551,8 @@ def state_handler( body: Whether to include the state change request in the body (`True`) or in the query string (`False`). This must be left as `None` if - providing one or more handler functions; and it must be set to - a boolean if providing a URL. + providing one or more handler functions; and it must be set to a + boolean if providing a URL. """ # A tuple is required instead of `StateHandlerUrl` for support for # Python 3.9. This should be changed to `StateHandlerUrl` in the future. @@ -587,7 +562,7 @@ def state_handler( raise ValueError(msg) return self._state_handler_url(handler, teardown=teardown, body=body) - if isinstance(handler, dict): + if isinstance(handler, Mapping): if body is not None: msg = "The `body` parameter must be `None` when providing a dictionary" raise ValueError(msg) @@ -648,8 +623,7 @@ def _state_handler_url( def _state_handler_dict( self, - handler: dict[str, StateHandlerNoState] - | dict[str, StateHandlerNoActionNoState], + handler: Mapping[str, Callable[..., None]], *, teardown: bool, ) -> Self: @@ -685,40 +659,16 @@ def _state_handler_dict( }, ) - if teardown: - if any( - len(inspect.signature(f).parameters) != 2 # noqa: PLR2004 - for f in handler.values() - ): - msg = "All functions must take two arguments: action and parameters" - raise TypeError(msg) - - handler_map = typing.cast("dict[str, StateHandlerNoState]", handler) - - def _handler( - state: str, - action: str, - parameters: dict[str, Any] | None, - ) -> None: - handler_map[state](action, parameters) - - else: - if any(len(inspect.signature(f).parameters) != 1 for f in handler.values()): - msg = "All functions must take one argument: parameters" - raise TypeError(msg) - - handler_map_no_action = typing.cast( - "dict[str, StateHandlerNoActionNoState]", - handler, + def _handler( + state: str, + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, + ) -> None: + apply_args( + handler[state], + StateHandlerArgs(state=state, action=action, parameters=parameters), ) - def _handler( - state: str, - action: str, # noqa: ARG001 - parameters: dict[str, Any] | None, - ) -> None: - handler_map_no_action[state](parameters) - self._state_handler = StateCallback(_handler) pact.v3.ffi.verifier_set_provider_state( self._handle, @@ -731,7 +681,7 @@ def _handler( def _set_function_state_handler( self, - handler: StateHandlerFull | StateHandlerNoAction, + handler: Callable[..., None], *, teardown: bool, ) -> Self: @@ -764,36 +714,15 @@ def _set_function_state_handler( }, ) - if teardown: - if len(inspect.signature(handler).parameters) != 3: # noqa: PLR2004 - msg = ( - "The function must take three arguments: " - "state, action, and parameters." - ) - raise TypeError(msg) - - handler_fn_full = typing.cast("StateHandlerFull", handler) - - def _handler( - state: str, - action: str, - parameters: dict[str, Any] | None, - ) -> None: - handler_fn_full(state, action, parameters) - - else: - if len(inspect.signature(handler).parameters) != 2: # noqa: PLR2004 - msg = "The function must take two arguments: state and parameters" - raise TypeError(msg) - - handler_fn_no_action = typing.cast("StateHandlerNoAction", handler) - - def _handler( - state: str, - action: str, # noqa: ARG001 - parameters: dict[str, Any] | None, - ) -> None: - handler_fn_no_action(state, parameters) + def _handler( + state: str, + action: Literal["setup", "teardown"], + parameters: dict[str, Any] | None, + ) -> None: + apply_args( + handler, + StateHandlerArgs(state=state, action=action, parameters=parameters), + ) self._state_handler = StateCallback(_handler) pact.v3.ffi.verifier_set_provider_state( diff --git a/tests/v3/compatibility_suite/util/provider.py b/tests/v3/compatibility_suite/util/provider.py index f678c92c5..b5d0cc953 100644 --- a/tests/v3/compatibility_suite/util/provider.py +++ b/tests/v3/compatibility_suite/util/provider.py @@ -894,9 +894,9 @@ def _( logger.info("Configuring provider state callback") def _callback( - _name: str, - _action: str, - _params: dict[str, str] | None, + state: str, + action: str, + parameters: dict[str, str] | None, ) -> None: pass @@ -1267,7 +1267,10 @@ def _( logger.debug("Calls: %s", provider_callback.call_args_list) provider_callback.assert_called() for calls in provider_callback.call_args_list: - if calls.args[0] == state and calls.args[1] == action: + if ( + calls.kwargs.get("state") == state + and calls.kwargs.get("action") == action + ): return msg = f"No {action} call found" @@ -1305,8 +1308,11 @@ def _( provider_callback.assert_called() for calls in provider_callback.call_args_list: - if calls.args[0] == state and calls.args[1] == action: - assert calls.args[2] == params + if ( + calls.kwargs.get("state") == state + and calls.kwargs.get("action") == action + and calls.kwargs.get("parameters") == params + ): return msg = f"No {action} call found" raise AssertionError(msg) From e4eff9cdf6b75b9b867c5f9ae1bbccd8e1f1aab8 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 28 Mar 2025 13:39:17 +1100 Subject: [PATCH 3/8] docs: update blog post To ensure the blog post remains relevant, update the blog post to reflect the most recent changes. Add a notice at the bottom indicating that there was an update. Signed-off-by: JP-Ellis --- .../posts/2024/12-30 functional arguments.md | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/docs/blog/posts/2024/12-30 functional arguments.md b/docs/blog/posts/2024/12-30 functional arguments.md index 999fdaa77..fe5bb75e1 100644 --- a/docs/blog/posts/2024/12-30 functional arguments.md +++ b/docs/blog/posts/2024/12-30 functional arguments.md @@ -90,7 +90,7 @@ The new `state_handler` method replaces the `set_state` method and simplifies th def provider_state_callback( name: str, # (1) action: Literal["setup", "teardown"], # (2) - params: dict[str, Any] | None, # (3) + parameters: dict[str, Any] | None, # (3) ) -> None: """ Callback to set up and tear down the provider state. @@ -105,7 +105,7 @@ The new `state_handler` method replaces the `set_state` method and simplifies th action should create the provider state, and the teardown action should remove it. - params: + parameters: If the provider state has additional parameters, they will be passed here. For example, instead of `"a user with ID 123 exists"`, the provider state might be `"a user with the given ID exists"` and @@ -123,9 +123,11 @@ The new `state_handler` method replaces the `set_state` method and simplifies th 3. The `params` parameter is a dictionary of additional parameters that the provider state requires. For example, instead of `"a user with ID 123 exists"`, the provider state might be `"a user with the given ID exists"` and the specific ID would be passed in the `params` dictionary. Note that `params` is always present, but may be `None` if no parameters are specified by the consumer. +The function arguments must include the relevant keys from the [`StateHandlerArgs`][pact.v3.types.StateHandlerArgs] typed dictionary. Pact Python will then intelligently determine how to pass the arguments in to your function, whether it be through positional or keyword arguments, or through variadic arguments. + This snippet showcases a way to set up the provider state with a function that is fully parameterized. The `state_handler` method also handles the following scenarios: -- If teardowns are never required, then one should specify `teardown=False` in which case the `action` parameter is _not_ passed to the callback function. +- If teardowns are never required, then one should specify `teardown=False` in which case the `action` parameter can be omitted from the signature of the callback function. This is useful when the provider state does not require any cleanup after the test has run. ??? example @@ -135,7 +137,7 @@ This snippet showcases a way to set up the provider state with a function that i def provider_state_callback( name: str, - params: dict[str, Any] | None, + parameters: dict[str, Any] | None, ) -> None: ... @@ -155,13 +157,13 @@ This snippet showcases a way to set up the provider state with a function that i def user_state_callback( action: Literal["setup", "teardown"], - params: dict[str, Any] | None, + parameters: dict[str, Any] | None, ) -> None: ... def no_users_state_callback( action: Literal["setup", "teardown"], - params: dict[str, Any] | None, + parameters: dict[str, Any] | None, ) -> None: ... @@ -176,7 +178,7 @@ This snippet showcases a way to set up the provider state with a function that i ``` -- Both scenarios can be combined, in which a mapping of provide state names to functions is provided, and the `teardown=False` option is specified. In this case, the function should expect only one argument: the `params` dictionary (which itself may be `None`). +- Both scenarios can be combined, in which a mapping of provide state names to functions is provided, and the `teardown=False` option is specified. In this case, the function should expect only one argument: the `parameters` dictionary (which itself may be `None`). ??? example @@ -185,12 +187,12 @@ This snippet showcases a way to set up the provider state with a function that i from pact.v3 import Verifier def user_state_callback( - params: dict[str, Any] | None, + parameters: dict[str, Any] | None, ) -> None: ... def no_users_state_callback( - params: dict[str, Any] | None, + parameters: dict[str, Any] | None, ) -> None: ... @@ -220,7 +222,7 @@ With the update to 2.3.0, the `Verifier` class has a new `message_handler` metho def message_producer_callback( name: str, # (1) - params: dict[str, Any] | None, # (2) + metadata: dict[str, Any] | None, # (2) ) -> Message: """ Callback to produce the message that the consumer expects. @@ -229,10 +231,8 @@ With the update to 2.3.0, the `Verifier` class has a new `message_handler` metho name: The name of the message. For example `"request to delete a user"`. - params: - If the message has additional parameters, they will be passed here. - For example, one could specify the user ID to delete in the - parameters instead of the message. + metadata: + Metadata that is passed along with the message. This could include information about the queue name, message type, creation timestamp, etc. Returns: The message that the consumer expects. @@ -247,6 +247,7 @@ With the update to 2.3.0, the `Verifier` class has a new `message_handler` metho 1. The `name` parameter is the name of the message. For example, `"request to delete a user"`. If you instead use a mapping of message names to functions, this parameter is not passed to the function. 2. The `params` parameter is a dictionary of additional parameters that the message requires. For example, one could specify the user ID to delete in the parameters instead of the message. Note that `params` is always present, but may be `None` if no parameters are specified by the consumer. +The function arguments must include the relevant keys from the [`MessageProducerArgs`][pact.v3.types.MessageProducerArgs] typed dictionary. Pact Python will then intelligently determine how to pass the arguments in to your function, whether it be through positional or keyword arguments, or through variadic arguments. The output of the callback function should be an instance of the `Message` type. This is a simple [TypedDict][typing.TypedDict] that represents the message that the consumer expects and can be specified as a simple dictionary, or with typing hints through the `Message` constructor: @@ -289,7 +290,7 @@ The output of the callback function should be an instance of the `Message` type. } ``` -In much the same way as the `state_handler` method, the `message_handler` method can also accept a mapping of message names to functions or raw messages. The function should expect only one argument: the `params` dictionary (which itself may be `None`); or if the message is static, the message can be provided directly: +In much the same way as the `state_handler` method, the `message_handler` method can also accept a mapping of message names to functions or raw messages. The function should expect only one argument: the `metadata` dictionary (which itself may be `None`); or if the message is static, the message can be provided directly: ???+ example @@ -298,7 +299,7 @@ In much the same way as the `state_handler` method, the `message_handler` method from pact.v3 import Verifier from pact.v3.types import Message - def delete_user_message(params: dict[str, Any] | None) -> Message: + def delete_user_message(metadata: dict[str, Any] | None) -> Message: ... def test_provider(): @@ -315,3 +316,12 @@ In much the same way as the `state_handler` method, the `message_handler` method ) ``` + +---- + + +28 March 2025 +: This blog post was updated on 28 March 2025 to reflect changes to the way functional arguments are handled. Instead of requiring positional arguments, Pact Python now inspects the function signature in order to determine whether to pass the arguments as positional or keyword arguments. It will fallback to passing the arguments through variadic arguments (`*args` and `**kwargs`) if present. This was done specific to allow for functions with optional arguments. + + For this added flexibility, the function signatures must have parameters that align with the [`StateHandlerArgs`][pact.v3.types.StateHandlerArgs] and [`MessageProducerArgs`][pact.v3.types.MessageProducerArgs] typed dictionaries. This allows Pact Python to match a `parameters=...` argument with the `parameters` key in the dictionary. Using an alternative name (e.g., `params`) will not work. + From 6922685fa8ecd294ac35a02b7381c8b3c84992db Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 28 Mar 2025 19:36:53 +1100 Subject: [PATCH 4/8] docs: rename params -> parameters Thanks to @lotruheawea for spotting some missed renames. Co-authored-by: lotruheawea --- docs/blog/posts/2024/12-30 functional arguments.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/2024/12-30 functional arguments.md b/docs/blog/posts/2024/12-30 functional arguments.md index fe5bb75e1..3949e24cc 100644 --- a/docs/blog/posts/2024/12-30 functional arguments.md +++ b/docs/blog/posts/2024/12-30 functional arguments.md @@ -120,7 +120,7 @@ The new `state_handler` method replaces the `set_state` method and simplifies th 1. The `name` parameter is the name of the provider state. For example, `"a user with ID 123 exists"` or `"no users exist"`. If you instead use a mapping of provider state names to functions, this parameter is not passed to the function. 2. The `action` parameter is either `"setup"` or `"teardown"`. The setup action should create the provider state, and the teardown action should remove it. If you specify `teardown=False`, then the `action` parameter is _not_ passed to the callback function. - 3. The `params` parameter is a dictionary of additional parameters that the provider state requires. For example, instead of `"a user with ID 123 exists"`, the provider state might be `"a user with the given ID exists"` and the specific ID would be passed in the `params` dictionary. Note that `params` is always present, but may be `None` if no parameters are specified by the consumer. + 3. The `parameters` parameter is a dictionary of additional parameters that the provider state requires. For example, instead of `"a user with ID 123 exists"`, the provider state might be `"a user with the given ID exists"` and the specific ID would be passed in the `parameters` dictionary. Note that `parameters` is always present, but may be `None` if no parameters are specified by the consumer. The function arguments must include the relevant keys from the [`StateHandlerArgs`][pact.v3.types.StateHandlerArgs] typed dictionary. Pact Python will then intelligently determine how to pass the arguments in to your function, whether it be through positional or keyword arguments, or through variadic arguments. From 65f47b8a5c4d44d396a5175680d06051c24e4820 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Apr 2025 11:40:01 +1100 Subject: [PATCH 5/8] chore(tests): use consistent return value Instead of combining things into strings or lists, use a consistent `NamedTuple` to capture the mixture of arguments. Signed-off-by: JP-Ellis --- tests/v3/test_util.py | 257 +++++++++++++++++++++++++++++++----------- 1 file changed, 189 insertions(+), 68 deletions(-) diff --git a/tests/v3/test_util.py b/tests/v3/test_util.py index 14d900e56..5a0f86a2a 100644 --- a/tests/v3/test_util.py +++ b/tests/v3/test_util.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NamedTuple import pytest @@ -46,39 +46,87 @@ def test_convert_python_to_java_datetime_format_with_single_quote() -> None: assert strftime_to_simple_date_format("%Y'%m'%d") == "yyyy''MM''dd" +class Args(NamedTuple): + """ + Named tuple to hold the arguments passed to a function. + """ + + args: dict[str, Any] + kwargs: dict[str, Any] + variadic_args: list[Any] + variadic_kwargs: dict[str, Any] + + def no_annotations(a, b, c, d=b"d"): # noqa: ANN001, ANN201 # type: ignore[reportUnknownArgumentType] - return f"{a}:{b}:{c}:{d!r}" + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) -def annotated(a: int, b: str, c: float, d: bytes = b"d") -> str: - return f"{a}:{b}:{c}:{d!r}" +def annotated(a: int, b: str, c: float, d: bytes = b"d") -> Args: + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) -def mixed(a: int, /, b: str, *, c: float, d: bytes = b"d") -> str: - return f"{a}:{b}:{c}:{d!r}" +def mixed(a: int, /, b: str, *, c: float, d: bytes = b"d") -> Args: + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) -def variadic_args(*args: Any) -> str: # noqa: ANN401 - return ":".join(str(arg) for arg in args) +def variadic_args(*args: Any) -> Args: # noqa: ANN401 + return Args( + args={}, + kwargs={}, + variadic_args=list(args), + variadic_kwargs={}, + ) -def variadic_kwargs(**kwargs: Any) -> str: # noqa: ANN401 - return ":".join(str(v) for v in kwargs.values()) +def variadic_kwargs(**kwargs: Any) -> Args: # noqa: ANN401 + return Args( + args={}, + kwargs=kwargs, + variadic_args=[], + variadic_kwargs={**kwargs}, + ) -def variadic_args_kwargs(*args: Any, **kwargs: Any) -> list[str]: # noqa: ANN401 - return [ - ":".join(str(arg) for arg in args), - ":".join(str(v) for v in kwargs.values()), - ] +def variadic_args_kwargs(*args: Any, **kwargs: Any) -> Args: # noqa: ANN401 + return Args( + args={}, + kwargs=kwargs, + variadic_args=list(args), + variadic_kwargs={**kwargs}, + ) -def mixed_variadic_args(a: int, *args: Any, d: bytes = b"d") -> list[str]: # noqa: ANN401 - return [f"{a}:{d!r}", ":".join(str(arg) for arg in args)] +def mixed_variadic_args(a: int, *args: Any, d: bytes = b"d") -> Args: # noqa: ANN401 + return Args( + args={"a": a, "d": d}, + kwargs={}, + variadic_args=list(args), + variadic_kwargs={}, + ) -def mixed_variadic_kwargs(a: int, d: bytes = b"d", **kwargs: Any) -> list[str]: # noqa: ANN401 - return [f"{a}:{d!r}", ":".join(str(v) for v in kwargs.values())] +def mixed_variadic_kwargs(a: int, d: bytes = b"d", **kwargs: Any) -> Args: # noqa: ANN401 + return Args( + args={"a": a, "d": d}, + kwargs=kwargs, + variadic_args=[], + variadic_kwargs={**kwargs}, + ) def mixed_variadic_args_kwargs( @@ -86,119 +134,192 @@ def mixed_variadic_args_kwargs( *args: Any, # noqa: ANN401 d: bytes = b"d", **kwargs: Any, # noqa: ANN401 -) -> list[str]: - return [ - f"{a}:{d!r}", - ":".join(str(arg) for arg in args), - ":".join(str(v) for v in kwargs.values()), - ] +) -> Args: + return Args( + args={"a": a, "d": d}, + kwargs=kwargs, + variadic_args=list(args), + variadic_kwargs={**kwargs}, + ) class Foo: # noqa: D101 def __init__(self) -> None: # noqa: D107 pass - def __call__(self, a: int, b: str, c: float, d: bytes = b"d") -> str: # noqa: D102 - return f"{a}:{b}:{c}:{d!r}" - - def method(self, a: int, b: str, c: float, d: bytes = b"d") -> str: # noqa: D102 - return f"{a}:{b}:{c}:{d!r}" + def __call__(self, a: int, b: str, c: float, d: bytes = b"d") -> Args: # noqa: D102 + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) + + def method(self, a: int, b: str, c: float, d: bytes = b"d") -> Args: # noqa: D102 + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) @classmethod - def class_method(cls, a: int, b: str, c: float, d: bytes = b"d") -> str: # noqa: D102 - return f"{a}:{b}:{c}:{d!r}" + def class_method(cls, a: int, b: str, c: float, d: bytes = b"d") -> Args: # noqa: D102 + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) @staticmethod - def static_method(a: int, b: str, c: float, d: bytes = b"d") -> str: # noqa: D102 - return f"{a}:{b}:{c}:{d!r}" + def static_method(a: int, b: str, c: float, d: bytes = b"d") -> Args: # noqa: D102 + return Args( + args={"a": a, "b": b, "c": c, "d": d}, + kwargs={}, + variadic_args=[], + variadic_kwargs={}, + ) @pytest.mark.parametrize( ("func", "args", "expected"), [ - (no_annotations, {"a": 1, "b": "b", "c": 3.14}, "1:b:3.14:b'd'"), - (no_annotations, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, "1:b:3.14:b'e'"), - (annotated, {"a": 1, "b": "b", "c": 3.14}, "1:b:3.14:b'd'"), - (annotated, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, "1:b:3.14:b'e'"), - (mixed, {"a": 1, "b": "b", "c": 3.14}, "1:b:3.14:b'd'"), - (mixed, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, "1:b:3.14:b'e'"), - (variadic_args, {"a": 1, "b": "b", "c": 3.14}, "1:b:3.14"), - (variadic_args, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, "1:b:3.14:b'e'"), - (variadic_kwargs, {"a": 1, "b": "b", "c": 3.14}, "1:b:3.14"), - (variadic_kwargs, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, "1:b:3.14:b'e'"), - (variadic_args_kwargs, {"a": 1, "b": "b", "c": 3.14}, ["", "1:b:3.14"]), + ( + no_annotations, + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), + ), + ( + no_annotations, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), + ), + ( + annotated, + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), + ), + ( + annotated, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), + ), + ( + mixed, + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), + ), + ( + mixed, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), + ), + ( + variadic_args, + {"a": 1, "b": "b", "c": 3.14}, + Args({}, {}, [1, "b", 3.14], {}), + ), + ( + variadic_args, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({}, {}, [1, "b", 3.14, b"e"], {}), + ), + ( + variadic_kwargs, + {"a": 1, "b": "b", "c": 3.14}, + Args({}, {"a": 1, "b": "b", "c": 3.14}, [], {"a": 1, "b": "b", "c": 3.14}), + ), + ( + variadic_kwargs, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args( + {}, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + [], + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + ), + ), + ( + variadic_args_kwargs, + {"a": 1, "b": "b", "c": 3.14}, + Args({}, {"a": 1, "b": "b", "c": 3.14}, [], {"a": 1, "b": "b", "c": 3.14}), + ), ( variadic_args_kwargs, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, - ["", "1:b:3.14:b'e'"], + Args( + {}, + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + [], + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + ), ), - (mixed_variadic_args, {"a": 1, "b": "b", "c": 3.14}, ["1:b'd'", "b:3.14"]), ( mixed_variadic_args, - {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, - ["1:b'e'", "b:3.14"], + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "d": b"d"}, {}, ["b", 3.14], {}), ), - (mixed_variadic_kwargs, {"a": 1, "b": "b", "c": 3.14}, ["1:b'd'", "b:3.14"]), ( - mixed_variadic_kwargs, + mixed_variadic_args, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, - ["1:b'e'", "b:3.14"], + Args({"a": 1, "d": b"e"}, {}, ["b", 3.14], {}), ), ( - mixed_variadic_args_kwargs, + mixed_variadic_kwargs, {"a": 1, "b": "b", "c": 3.14}, - ["1:b'd'", "", "b:3.14"], + Args({"a": 1, "d": b"d"}, {"b": "b", "c": 3.14}, [], {"b": "b", "c": 3.14}), ), ( - mixed_variadic_args_kwargs, + mixed_variadic_kwargs, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, - ["1:b'e'", "", "b:3.14"], + Args({"a": 1, "d": b"e"}, {"b": "b", "c": 3.14}, [], {"b": "b", "c": 3.14}), ), ( mixed_variadic_args_kwargs, - {"a": 1, "b": "b", "c": 3.14, "e": "f"}, - ["1:b'd'", "", "b:3.14:f"], + {"a": 1, "b": "b", "c": 3.14}, + Args({"a": 1, "d": b"d"}, {"b": "b", "c": 3.14}, [], {"b": "b", "c": 3.14}), ), ( mixed_variadic_args_kwargs, - {"a": 1, "b": "b", "c": 3.14, "e": "f", "d": b"e"}, - ["1:b'e'", "", "b:3.14:f"], + {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, + Args({"a": 1, "d": b"e"}, {"b": "b", "c": 3.14}, [], {"b": "b", "c": 3.14}), ), ( Foo(), {"a": 1, "b": "b", "c": 3.14}, - "1:b:3.14:b'd'", + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), ), ( Foo(), {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, - "1:b:3.14:b'e'", + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), ), ( Foo().class_method, {"a": 1, "b": "b", "c": 3.14}, - "1:b:3.14:b'd'", + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), ), ( Foo().class_method, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, - "1:b:3.14:b'e'", + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), ), ( Foo().static_method, {"a": 1, "b": "b", "c": 3.14}, - "1:b:3.14:b'd'", + Args({"a": 1, "b": "b", "c": 3.14, "d": b"d"}, {}, [], {}), ), ( Foo().static_method, {"a": 1, "b": "b", "c": 3.14, "d": b"e"}, - "1:b:3.14:b'e'", + Args({"a": 1, "b": "b", "c": 3.14, "d": b"e"}, {}, [], {}), ), ], # type: ignore[reportUnknownArgumentType] ) def test_apply_expected( - func: Callable[..., Any], + func: Callable[..., Args], args: dict[str, Any], - expected: str | list[str], + expected: Args, ) -> None: assert apply_args(func, args) == expected From 77670c2872109cb6122e0747c4929c7532f69fae Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Apr 2025 11:42:03 +1100 Subject: [PATCH 6/8] chore(test): tweak type signature Signed-off-by: JP-Ellis --- tests/v3/compatibility_suite/util/provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/v3/compatibility_suite/util/provider.py b/tests/v3/compatibility_suite/util/provider.py index b5d0cc953..7473cc9ef 100644 --- a/tests/v3/compatibility_suite/util/provider.py +++ b/tests/v3/compatibility_suite/util/provider.py @@ -896,7 +896,7 @@ def _( def _callback( state: str, action: str, - parameters: dict[str, str] | None, + parameters: dict[str, Any] | None, ) -> None: pass From 371e3444668dec017b5189272ada1c3c13220532 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Apr 2025 11:48:29 +1100 Subject: [PATCH 7/8] chore(examples): fix state handler args Signed-off-by: JP-Ellis --- examples/tests/v3/test_01_fastapi_provider.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/tests/v3/test_01_fastapi_provider.py b/examples/tests/v3/test_01_fastapi_provider.py index 6bdecf580..3484fdabe 100644 --- a/examples/tests/v3/test_01_fastapi_provider.py +++ b/examples/tests/v3/test_01_fastapi_provider.py @@ -146,7 +146,7 @@ def test_provider(server: str) -> None: def provider_state_handler( state: str, action: str, - _parameters: dict[str, Any] | None, + parameters: dict[str, Any] | None = None, # noqa: ARG001 ) -> None: """ Handler for the provider state callback. @@ -178,6 +178,10 @@ def provider_state_handler( state: The name of the state to set up or tear down. + parameters: + A dictionary of parameters to pass to the state handler. This is + not used in this example, but is included for completeness. + Returns: A dictionary containing the result of the action. """ From d5073f35cdb37c0ec0c60312f05277fe902c5854 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 1 Apr 2025 11:53:30 +1100 Subject: [PATCH 8/8] docs(example): elaborate on state handler --- examples/tests/v3/test_01_fastapi_provider.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/tests/v3/test_01_fastapi_provider.py b/examples/tests/v3/test_01_fastapi_provider.py index 3484fdabe..08bf78a38 100644 --- a/examples/tests/v3/test_01_fastapi_provider.py +++ b/examples/tests/v3/test_01_fastapi_provider.py @@ -170,6 +170,11 @@ def provider_state_handler( also be used to reset the mock, or in the case were a real database is used, to clean up any side effects. + This example showcases how a _full_ provider state handler can be + implemented. The handler can also be specified through a mapping of provider + states to functions. See the documentation of the + [`state_handler`][pact.v3.Verifier.state_handler] method for more details. + Args: action: One of `setup` or `teardown`. Determines whether the provider state