diff --git a/pact/v3/ffi.py b/pact/v3/ffi.py index b33b30c0e6..88d0f0e571 100644 --- a/pact/v3/ffi.py +++ b/pact/v3/ffi.py @@ -90,8 +90,9 @@ from ._ffi import ffi, lib # type: ignore[import] if TYPE_CHECKING: - import cffi from pathlib import Path + + import cffi from typing_extensions import Self # The follow types are classes defined in the Rust code. Ultimately, a Python @@ -814,6 +815,8 @@ class OwnedString(str): in most cases. """ + __slots__ = ("_ptr", "_string") + def __new__(cls, ptr: cffi.FFI.CData) -> Self: """ Create a new Owned String. @@ -3000,7 +3003,7 @@ def pact_message_iter_next(iter: PactMessageIterator) -> Message: ptr = lib.pactffi_pact_message_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration - raise NotImplementedError() + raise NotImplementedError return Message(ptr) @@ -3014,7 +3017,7 @@ def pact_sync_message_iter_next(iter: PactSyncMessageIterator) -> SynchronousMes ptr = lib.pactffi_pact_sync_message_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration - raise NotImplementedError() + raise NotImplementedError return SynchronousMessage(ptr) @@ -3038,7 +3041,7 @@ def pact_sync_http_iter_next(iter: PactSyncHttpIterator) -> SynchronousHttp: ptr = lib.pactffi_pact_sync_http_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration - raise NotImplementedError() + raise NotImplementedError return SynchronousHttp(ptr) @@ -3062,7 +3065,7 @@ def pact_interaction_iter_next(iter: PactInteractionIterator) -> PactInteraction ptr = lib.pactffi_pact_interaction_iter_next(iter._ptr) if ptr == ffi.NULL: raise StopIteration - raise NotImplementedError() + raise NotImplementedError return PactInteraction(ptr) @@ -5693,7 +5696,7 @@ def pact_handle_get_sync_message_iter(pact: PactHandle) -> PactSyncMessageIterat ('\0') bytes. """ return PactSyncMessageIterator( - lib.pactffi_pact_handle_get_sync_message_iter(pact._ref) + lib.pactffi_pact_handle_get_sync_message_iter(pact._ref), ) @@ -5929,6 +5932,9 @@ def pact_handle_write_file( This function should be called if all the consumer tests have passed. Args: + pact: + Handle to a Pact model. + directory: The directory to write the file to. If `None`, the current working directory is used. @@ -5947,9 +5953,9 @@ def pact_handle_write_file( return if ret == 1: msg = f"The function panicked while writing {pact} to {directory}." - elif ret == 2: + elif ret == 2: # noqa: PLR2004 msg = f"The pact file was not able to be written for {pact}." - elif ret == 3: + elif ret == 3: # noqa: PLR2004 msg = f"The pact for {pact} was not found." else: msg = f"Unknown error writing {pact} to {directory}." @@ -6616,6 +6622,9 @@ def using_plugin( `pactffi_using_plugin`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_using_plugin) Args: + pact: + Handle to a Pact model. + plugin_name: Name of the plugin to use. @@ -6632,9 +6641,9 @@ def using_plugin( return if ret == 1: msg = f"A general panic was caught: {get_error_message()}" - elif ret == 2: + elif ret == 2: # noqa: PLR2004 msg = f"Failed to load the plugin {plugin_name}." - elif ret == 3: + elif ret == 3: # noqa: PLR2004 msg = f"The Pact handle {pact} is invalid." else: msg = f"There was an unknown error loading the plugin {plugin_name}." diff --git a/pact/v3/pact.py b/pact/v3/pact.py index 19222d5b23..a2536ca54b 100644 --- a/pact/v3/pact.py +++ b/pact/v3/pact.py @@ -1108,6 +1108,13 @@ def serve( transport_config: Configuration for the transport. This is specific to the transport being used and should be a JSON string. + + raises: Whether to raise an exception if there are mismatches + between the Pact and the server. If set to `False`, then the + mismatches must be handled manually. + + Returns: + A [`PactServer`][pact.v3.pact.PactServer] instance. """ return PactServer( self._handle, @@ -1139,21 +1146,23 @@ def messages(self) -> pact.v3.ffi.PactMessageIterator: return pact.v3.ffi.pact_handle_get_message_iter(self._handle) @overload - def interactions(self, type: Literal["HTTP"]) -> pact.v3.ffi.PactSyncHttpIterator: + def interactions(self, kind: Literal["HTTP"]) -> pact.v3.ffi.PactSyncHttpIterator: ... @overload def interactions( - self, type: Literal["Sync"] + self, + kind: Literal["Sync"], ) -> pact.v3.ffi.PactSyncMessageIterator: ... @overload - def interactions(self, type: Literal["Async"]) -> pact.v3.ffi.PactMessageIterator: + def interactions(self, kind: Literal["Async"]) -> pact.v3.ffi.PactMessageIterator: ... def interactions( - self, type: str = "HTTP" + self, + kind: str = "HTTP", ) -> ( pact.v3.ffi.PactSyncHttpIterator | pact.v3.ffi.PactSyncMessageIterator @@ -1162,30 +1171,26 @@ def interactions( """ Return an iterator over the Pact's interactions. - The type is used to specify the kind of interactions that will be - iterated over. If `"All"` is specified (the default), then all - interactions will be iterated over. + The kind is used to specify the type of interactions that will be + iterated over. """ - # TODO: The FFI does not have a way to iterate over all interactions, unless - # you have a pointer to the pact. See - # pact-foundation/pact-reference#333. - # if type == "All": - # return pact.v3.ffi.pact_model_interaction_iterator(self._handle) - if type == "HTTP": + # TODO(JP-Ellis): Add an iterator for `All` interactions. + # https://github.com/pact-foundation/pact-python/issues/451 + if kind == "HTTP": return pact.v3.ffi.pact_handle_get_sync_http_iter(self._handle) - if type == "Sync": + if kind == "Sync": return pact.v3.ffi.pact_handle_get_sync_message_iter(self._handle) - if type == "Async": + if kind == "Async": return pact.v3.ffi.pact_handle_get_message_iter(self._handle) - msg = f"Unknown interaction type: {type}" + msg = f"Unknown interaction type: {kind}" raise ValueError(msg) def write_file( self, - directory: Path | str = Path.cwd(), + directory: Path | str | None = None, *, overwrite: bool = False, - ): + ) -> None: """ Write out the pact to a file. @@ -1204,6 +1209,8 @@ def write_file( exists. Otherwise, the contents of the file will be merged with the existing file. """ + if directory is None: + directory = Path.cwd() pact.v3.ffi.pact_handle_write_file( self._handle, directory, @@ -1259,6 +1266,9 @@ def __init__( # noqa: PLR0913 transport_config: Configuration for the transport. This is specific to the transport being used and should be a JSON string. + + raises: Whether or not to raise an exception if the server is not + matched upon exit. """ self._host = host self._port = port diff --git a/pyproject.toml b/pyproject.toml index 80b9769593..d2b18c90d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,9 +175,46 @@ ignore = [ "D212", # Multi-line docstring summary must start at the first line "ANN101", # `self` must be typed "ANN102", # `cls` must be typed + "FIX002", # Forbid TODO in comments ] -extend-exclude = ["tests/*.py", "pact/*.py"] +# TODO: Remove the explicity extend-exclude once astral-sh/ruff#6262 is fixed. +# https://github.com/pact-foundation/pact-python/issues/458 +extend-exclude = [ + # "pact/*.py", + # "pact/cli/*.py", + # "tests/*.py", + # "tests/cli/*.py", + "pact/__init__.py", + "pact/__version__.py", + "pact/broker.py", + "pact/cli/*.py", + "pact/constants.py", + "pact/consumer.py", + "pact/http_proxy.py", + "pact/matchers.py", + "pact/message_consumer.py", + "pact/message_pact.py", + "pact/message_provider.py", + "pact/pact.py", + "pact/provider.py", + "pact/verifier.py", + "pact/verify_wrapper.py", + "tests/__init__.py", + "tests/cli/*.py", + "tests/conftest.py", + "tests/test_broker.py", + "tests/test_constants.py", + "tests/test_consumer.py", + "tests/test_http_proxy.py", + "tests/test_matchers.py", + "tests/test_message_consumer.py", + "tests/test_message_pact.py", + "tests/test_message_provider.py", + "tests/test_pact.py", + "tests/test_verifier.py", + "tests/test_verify_wrapper.py", +] [tool.ruff.pyupgrade] keep-runtime-typing = true diff --git a/tests/v3/__init__.py b/tests/v3/__init__.py index e69de29bb2..bcfbefdf79 100644 --- a/tests/v3/__init__.py +++ b/tests/v3/__init__.py @@ -0,0 +1,3 @@ +""" +Pact Python v3 tests. +""" diff --git a/tests/v3/compatibility-suite/tests/test_v1_consumer.py b/tests/v3/compatibility-suite/tests/test_v1_consumer.py new file mode 100644 index 0000000000..504fbb38f9 --- /dev/null +++ b/tests/v3/compatibility-suite/tests/test_v1_consumer.py @@ -0,0 +1,337 @@ +"""Basic HTTP consumer feature tests.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any, Generator, TypedDict + +import requests +from pact.v3 import Pact +from pytest_bdd import given, parsers, scenario, then, when +from yarl import URL + +from .util import string_to_int + +if TYPE_CHECKING: + from pact.v3.pact import PactServer + + +@scenario( + "../features/V1/http_consumer.feature", + "When all requests are made to the mock server", +) +def test_when_all_requests_are_made_to_the_mock_server() -> None: + """When all requests are made to the mock server.""" + + +@scenario( + "../features/V1/http_consumer.feature", + "When not all requests are made to the mock server", +) +def test_when_not_all_requests_are_made_to_the_mock_server() -> None: + """When not all requests are made to the mock server.""" + + +class InteractionDefinition(TypedDict): + """ + Interaction definition. + + This is a dictionary that represents a single interaction. It is used to + parse the HTTP interactions table into a more useful format. + """ + + method: str + path: str + query: str + headers: list[tuple[str, str]] + body: str + response: int + response_content: str + response_body: str + + +@given( + parsers.parse("the following HTTP interactions have been defined:\n{content}"), + target_fixture="interaction_definitions", +) +def the_following_http_interactions_have_been_defined( + content: str, +) -> dict[int, InteractionDefinition]: + """ + Parse the HTTP interactions table into a dictionary. + + The table columns are expected to be: + + - No + - method + - path + - query + - headers + - body + - response + - response content + - response body + + The first row is ignored, as it is assumed to be the column headers. The + order of the columns is similarly ignored. + """ + rows = [ + list(map(str.strip, row.split("|")))[1:-1] + for row in content.split("\n") + if row.strip() + ] + + # Check that the table is well-formed + assert len(rows[0]) == 9 + assert rows[0][0] == "No" + + # Parse the table into a more useful format + interactions: dict[int, InteractionDefinition] = {} + for row in rows[1:]: + interactions[int(row[0])] = { + "method": row[1], + "path": row[2], + "query": row[3], + "headers": row[4], + "body": row[5], + "response": int(row[6]), + "response_content": row[7], + "response_body": row[8], + } + return interactions + + +@when( + parsers.re( + r"the mock server is started" + r" with interactions?" + r' "?(?P((\d+)(,\s)?)+)"?', + ), + converters={"ids": lambda s: list(map(int, s.split(",")))}, + target_fixture="srv", +) +def the_mock_server_is_started_with_interactions( + ids: list[int], + interaction_definitions: dict[int, InteractionDefinition], +) -> Generator[PactServer, Any, None]: + """The mock server is started with interactions.""" + pact = Pact("consumer", "provider") + for iid in ids: + definition = interaction_definitions[iid] + + interaction = pact.upon_receiving(f"interactions {iid}") + interaction.with_request(definition["method"], definition["path"]) + + if definition["query"]: + query = URL.build(query_string=definition["query"]).query + interaction.with_query_parameters(query.items()) + + if definition["headers"]: + interaction.with_header(*definition["headers"].split(": ")) + + if definition["body"] and definition["body"].startswith("file: "): + interaction.with_body_from_file( + Path("..") / "fixtures" / definition["body"][6:], + ) + + interaction.will_respond_with(definition["response"]) + + if definition["response_content"]: + interaction.with_header("Content-Type", definition["response_content"]) + if definition["response_body"].startswith("file: "): + interaction.with_body_from_file( + Path(__file__).parent.parent + / "fixtures" + / definition["response_body"][6:], + ) + + with pact.serve(raises=False) as srv: + yield srv + + +@when( + parsers.re( + r"request (?P\d+) is made to the mock server", + ), + converters={"request_id": int}, + target_fixture="response", +) +def request_n_is_made_to_the_mock_server( + interaction_definitions: dict[int, InteractionDefinition], + request_id: int, + srv: PactServer, +) -> requests.Response: + """ + Request n is made to the mock server. + """ + definition = interaction_definitions[request_id] + return requests.request( + definition["method"], + str(srv.url.with_path(definition["path"])), + params=URL.build(query_string=definition["query"]).query + if definition["query"] + else None, + headers=definition["headers"] if definition["headers"] else None, + data=definition["body"] if definition["body"] else None, + ) + + +@then( + parsers.re( + r"a (?P\d+) (success|error) response is returned", + ), + converters={"code": int}, +) +def a_response_is_returned(response: requests.Response, code: int) -> None: + """ + A response is returned. + """ + assert response.status_code == code + + +@then( + parsers.re( + r'the payload will contain the "(?P[^"]+)" JSON document', + ), +) +def the_payload_will_contain_the_json_document( + response: requests.Response, + file: str, +) -> None: + """ + The payload will contain the JSON document. + """ + path = Path(__file__).parent.parent / "fixtures" / f"{file}.json" + assert response.json() == json.loads(path.read_text()) + + +@then( + parsers.re( + r'the content type will be set as "(?P[^"]+)"', + ), +) +def the_content_type_will_be_set_as( + response: requests.Response, + content_type: str, +) -> None: + assert response.headers["Content-Type"] == content_type + + +@when("the pact test is done") +def the_pact_test_is_done() -> None: + """ + The pact test is done. + """ + + +@then( + parsers.re(r"the mock server status will (?P(NOT )?)be OK"), + converters={"negated": lambda s: s == "NOT "}, +) +def the_mock_server_status_will_be(srv: PactServer, *, negated: bool) -> None: + """ + The mock server status will be. + """ + assert srv.matched is not negated + + +@then( + parsers.re( + r"the mock server will (?P(NOT )?)" + r"write out a Pact file for the interactions? when done", + ), + converters={"negated": lambda s: s == "NOT "}, + target_fixture="pact_file", +) +def the_mock_server_will_write_out_a_pact_file_for_the_interaction_when_done( + srv: PactServer, + temp_dir: Path, + *, + negated: bool, +) -> dict[str, Any] | None: + """ + The mock server will write out a Pact file for the interaction when done. + """ + if not negated: + srv.write_file(temp_dir) + output = temp_dir / "consumer-provider.json" + assert output.is_file() + return json.load(output.open()) + return None + + +@then( + parsers.re(r"the pact file will contain \{(?P\d+)\} interactions?"), + converters={"n": int}, +) +def the_pact_file_will_contain_n_interactions( + pact_file: dict[str, Any], + n: int, +) -> None: + """ + The pact file will contain n interactions. + """ + assert len(pact_file["interactions"]) == n + + +@then( + parsers.re( + r'the \{(?P\w+)\} interaction request will be for a "(?P[A-Z]+)"', + ), + converters={"n": string_to_int}, +) +def the_nth_interaction_request_will_be_for_method( + pact_file: dict[str, Any], + n: int, + method: str, +) -> None: + """ + The nth interaction request will be for a method. + """ + assert pact_file["interactions"][n - 1]["request"]["method"] == method + + +@then( + parsers.re( + r"the \{(?P\w+)\} interaction" + r" response will contain" + r' the "(?P[^"]+)" document', + re.X, + ), + converters={"n": string_to_int}, +) +def the_nth_interaction_will_contain_the_document( + pact_file: dict[str, Any], + n: int, + file: str, +) -> None: + """ + The nth interaction response will contain the document. + """ + file_path = Path(__file__).parent.parent / "fixtures" / file + if file.endswith(".json"): + assert pact_file["interactions"][n - 1]["response"]["body"] == json.load( + file_path.open(), + ) + + +@then( + parsers.re( + r"the mock server status will be" + r" an expected but not received error" + r" for interaction \{(?P\d+)\}", + ), + converters={"n": int}, +) +def the_mock_server_status_will_be_an_expected_but_not_received_error_for_interaction_n( + srv: PactServer, + n: int, +) -> None: + """ + The mock server status will be an expected but not received error for interaction n. + """ + assert srv.matched is False + assert len(srv.mismatches) > 0 + assert srv.mismatches[n - 1]["type"] == "request" diff --git a/tests/v3/compatibility-suite/tests/util.py b/tests/v3/compatibility-suite/tests/util.py new file mode 100644 index 0000000000..35a069b96e --- /dev/null +++ b/tests/v3/compatibility-suite/tests/util.py @@ -0,0 +1,51 @@ +""" +Utility functions to help with testing. +""" + + +def string_to_int(word: str) -> int: + """ + Convert a word to an integer. + + The word can be a number, or a word representing a number. + + Args: + word: The word to convert. + + Returns: + The integer value of the word. + + Raises: + ValueError: If the word cannot be converted to an integer. + """ + try: + return int(word) + except ValueError: + pass + + try: + return { + "first": 1, + "second": 2, + "third": 3, + "fourth": 4, + "fifth": 5, + "sixth": 6, + "seventh": 7, + "eighth": 8, + "ninth": 9, + "1st": 1, + "2nd": 2, + "3rd": 3, + "4th": 4, + "5th": 5, + "6th": 6, + "7th": 7, + "8th": 8, + "9th": 9, + }[word] + except KeyError: + pass + + msg = f"Unable to convert {word!r} to an integer" + raise ValueError(msg) diff --git a/tests/v3/test_ffi.py b/tests/v3/test_ffi.py index 912411c53d..53e00361da 100644 --- a/tests/v3/test_ffi.py +++ b/tests/v3/test_ffi.py @@ -65,5 +65,5 @@ def test_owned_string() -> None: ( "-----END CERTIFICATE-----\n", "-----END CERTIFICATE-----\r\n", - ) + ), ) diff --git a/tests/v3/test_http_interaction.py b/tests/v3/test_http_interaction.py index 5894c04895..fa5d6cbe09 100644 --- a/tests/v3/test_http_interaction.py +++ b/tests/v3/test_http_interaction.py @@ -105,11 +105,7 @@ async def test_with_header_request( ) with pact.serve() as srv: async with aiohttp.ClientSession(srv.url) as session: - async with session.request( - "GET", - "/", - headers=headers, - ) as resp: + async with session.request("GET", "/", headers=headers) as resp: assert resp.status == 200 @@ -134,10 +130,7 @@ async def test_with_header_response( ) with pact.serve() as srv: async with aiohttp.ClientSession(srv.url) as session: - async with session.request( - "GET", - "/", - ) as resp: + async with session.request("GET", "/") as resp: assert resp.status == 200 response_headers = [(h.lower(), v) for h, v in resp.headers.items()] for header, value in headers: @@ -182,11 +175,7 @@ async def test_set_header_request( ) with pact.serve() as srv: async with aiohttp.ClientSession(srv.url) as session: - async with session.request( - "GET", - "/", - headers=headers, - ) as resp: + async with session.request("GET", "/", headers=headers) as resp: assert resp.status == 200 @@ -205,11 +194,7 @@ async def test_set_header_request_repeat( ) with pact.serve() as srv: async with aiohttp.ClientSession(srv.url) as session: - async with session.request( - "GET", - "/", - headers=headers, - ) as resp: + async with session.request("GET", "/", headers=headers) as resp: assert resp.status == 500 @@ -233,10 +218,7 @@ async def test_set_header_response( ) with pact.serve() as srv: async with aiohttp.ClientSession(srv.url) as session: - async with session.request( - "GET", - "/", - ) as resp: + async with session.request("GET", "/") as resp: assert resp.status == 200 response_headers = [(h.lower(), v) for h, v in resp.headers.items()] for header, value in headers: @@ -258,11 +240,7 @@ async def test_set_header_response_repeat( ) with pact.serve() as srv: async with aiohttp.ClientSession(srv.url) as session: - async with session.request( - "GET", - "/", - headers=headers, - ) as resp: + async with session.request("GET", "/", headers=headers) as resp: assert resp.status == 200 response_headers = [(h.lower(), v) for h, v in resp.headers.items()] assert ("x-test", "2") in response_headers @@ -309,10 +287,7 @@ async def test_with_query_parameter_request( with pact.serve() as srv: async with aiohttp.ClientSession(srv.url) as session: url = srv.url.with_query(query) - async with session.request( - "GET", - url.path_qs, - ) as resp: + async with session.request("GET", url.path_qs) as resp: assert resp.status == 200 @@ -327,10 +302,7 @@ async def test_with_query_parameter_dict(pact: Pact) -> None: 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: + async with session.request("GET", url.path_qs) as resp: assert resp.status == 200 @@ -508,12 +480,14 @@ async def test_multipart_file_request(pact: Pact, temp_dir: Path) -> None: with pact.serve() as srv, aiohttp.MultipartWriter() as mpwriter: mpwriter.append( fpy.open("rb"), - # TODO: Remove type ignore once aio-libs/aiohttp#7741 is resolved + # TODO(JP-Ellis): Remove type ignore once aio-libs/aiohttp#7741 is resolved + # https://github.com/pact-foundation/pact-python/issues/450 {"Content-Type": "text/x-python"}, # type: ignore[arg-type] ) mpwriter.append( fpng.open("rb"), - # TODO: Remove type ignore once aio-libs/aiohttp#7741 is resolved + # TODO(JP-Ellis): Remove type ignore once aio-libs/aiohttp#7741 is resolved + # https://github.com/pact-foundation/pact-python/issues/450 {"Content-Type": "image/png"}, # type: ignore[arg-type] ) diff --git a/tests/v3/test_pact.py b/tests/v3/test_pact.py index 4759be6153..64fd520a60 100644 --- a/tests/v3/test_pact.py +++ b/tests/v3/test_pact.py @@ -3,13 +3,16 @@ """ from __future__ import annotations + import json -from pathlib import Path -from typing import Literal +from typing import TYPE_CHECKING, Literal import pytest from pact.v3 import Pact +if TYPE_CHECKING: + from pathlib import Path + @pytest.fixture() def pact() -> Pact: @@ -84,9 +87,8 @@ def test_interactions_iter( for _interaction in interactions: # This should be an empty list and therefore the error should never be # raised. - raise RuntimeError("Should not be reached") - else: - print("Ok") + msg = "Should not be reached" + raise RuntimeError(msg) def test_messages(pact: Pact) -> None: @@ -95,9 +97,8 @@ def test_messages(pact: Pact) -> None: for _message in messages: # This should be an empty list and therefore the error should never be # raised. - raise RuntimeError("Should not be reached") - else: - print("Ok") + msg = "Should not be reached" + raise RuntimeError(msg) def test_write_file(pact: Pact, temp_dir: Path) -> None: