diff --git a/.gitignore b/.gitignore index 761f69890..12d287842 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ # Test outputs examples/tests/pacts +# Version file generated at build time +src/pact/__version__.py + # Wheels from CIBuildWheel wheelhouse/ diff --git a/docs/consumer.md b/docs/consumer.md index 7c69684a9..d23629556 100644 --- a/docs/consumer.md +++ b/docs/consumer.md @@ -144,7 +144,7 @@ def test_get_user(pact: Pact) -> None: 9. The `pact.serve()` method starts the mock service, and the `srv` object provides the URL of the mock service. Within this context, any requests made to the mock service will be handled according to the interactions defined on the `pact` object. Once the context is exited, the mock service is stopped, and the interactions are verified to ensure all expected requests were made. -10. The `UserClient` is instantiated with this URL, and the `get_user` method is called to retrieve the user data. The test asserts that the returned user's name is "Alice". +10. The `UserClient` is instantiated with this URL, and the `get_user` method is called to retrieve the user data. The test asserts that the returned user's name is "Alice". `srv.url` is a [`URL`][pact.types.URL], which is a `str`; `str()` is optional, and `srv.url / "users" / "123"` appends path segments. The test begins with a Pytest fixture that creates a reusable Pact instance representing the contract between `"user-consumer"` and `"user-provider"`. The expected response is defined using flexible matchers (`match.int()`, `match.str()`, `match.datetime()`) to validate data types rather than exact values, making the test more robust against varying response data. diff --git a/examples/plugins/protobuf/test_provider.py b/examples/plugins/protobuf/test_provider.py index f42e49faa..33a5997b0 100644 --- a/examples/plugins/protobuf/test_provider.py +++ b/examples/plugins/protobuf/test_provider.py @@ -28,7 +28,6 @@ import uvicorn from fastapi import FastAPI, HTTPException from fastapi.responses import Response -from yarl import URL from examples.plugins.proto.person_pb2 import AddressBook from examples.plugins.protobuf import address_book @@ -38,7 +37,8 @@ from collections.abc import Generator from pathlib import Path -PROVIDER_URL = URL("http://localhost:8001") +PROVIDER_HOST = "localhost" +PROVIDER_PORT = 8001 # Global variable to hold our mock address book data # In a real application, this would be a database or other data store @@ -130,13 +130,11 @@ def server() -> Generator[str, None, None]: Yields: The URL of the running server. """ - assert PROVIDER_URL.host is not None - assert PROVIDER_URL.port is not None server = Server( uvicorn.Config( app, - host=PROVIDER_URL.host, - port=PROVIDER_URL.port, + host=PROVIDER_HOST, + port=PROVIDER_PORT, ) ) with server.run_in_thread() as url: diff --git a/pact-python-ffi/.gitignore b/pact-python-ffi/.gitignore index 48af8469c..897d064d6 100644 --- a/pact-python-ffi/.gitignore +++ b/pact-python-ffi/.gitignore @@ -1,3 +1,4 @@ +src/pact_ffi/__version__.py src/pact_ffi/data src/pact_ffi/*.pyd src/pact_ffi/*.so diff --git a/pact-python-ffi/pyproject.toml b/pact-python-ffi/pyproject.toml index 83cc9b7e0..d373b0155 100644 --- a/pact-python-ffi/pyproject.toml +++ b/pact-python-ffi/pyproject.toml @@ -80,6 +80,16 @@ build-backend = "hatchling.build" [tool.hatch.build] packages = ["src/pact_ffi"] + [tool.hatch.build.hooks.version] + path = "src/pact_ffi/__version__.py" + template = """\ +# Generated at build time from the project version. Do not edit. +__version__ = version = {version!r} +__version_tuple__ = version_tuple = tuple( + int(part) if part.isdigit() else part for part in __version__.split(".") +) +""" + [tool.hatch.build.targets.wheel.hooks.custom] patch = "hatch_build.py" diff --git a/pact-python-ffi/src/pact_ffi/__version__.py b/pact-python-ffi/src/pact_ffi/__version__.py deleted file mode 100644 index bb75cdc87..000000000 --- a/pact-python-ffi/src/pact_ffi/__version__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Version information for pact-python-ffi.""" - -from importlib.metadata import PackageNotFoundError, version - -try: - __version__ = version("pact-python-ffi") -except PackageNotFoundError: - __version__ = "unknown" - -__version_tuple__ = tuple(int(x) for x in __version__.split(".") if x.isdigit()) diff --git a/pyproject.toml b/pyproject.toml index ab062c077..efef5d9b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,6 @@ dependencies = [ "pact-python-ffi~=0.5.0", "typing-extensions~=4.0 ; python_version < '3.13'", # External dependencies - "yarl~=1.0", ] [project.urls] @@ -134,6 +133,16 @@ build-backend = "hatchling.build" [tool.hatch.build] packages = ["src/pact"] + [tool.hatch.build.hooks.version] + path = "src/pact/__version__.py" + template = """\ +# Generated at build time from the project version. Do not edit. +__version__ = version = {version!r} +__version_tuple__ = version_tuple = tuple( + int(part) if part.isdigit() else part for part in __version__.split(".") +) +""" + [tool.hatch.envs] [tool.hatch.envs.default] installer = "uv" diff --git a/src/pact/__version__.py b/src/pact/__version__.py deleted file mode 100644 index 3b6fe0250..000000000 --- a/src/pact/__version__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Version information for pact-python.""" - -from importlib.metadata import PackageNotFoundError, version - -try: - __version__ = version("pact-python") -except PackageNotFoundError: - __version__ = "unknown" - -__version_tuple__ = tuple(int(x) for x in __version__.split(".") if x.isdigit()) diff --git a/src/pact/_url.py b/src/pact/_url.py new file mode 100644 index 000000000..b5ae5a48c --- /dev/null +++ b/src/pact/_url.py @@ -0,0 +1,105 @@ +""" +URL type returned by Pact servers. +""" + +from __future__ import annotations + +from functools import cached_property +from urllib.parse import SplitResult, quote, urlsplit, urlunsplit + +DEFAULT_PORTS = {"http": 80, "https": 443} + +# RFC 3986 `pchar` characters, which stay unencoded in appended segments. +_PATH_SAFE = "/:@!$&'()*+,;=" + + +class URL(str): + """ + A URL which supports appending path segments with `/`. + + `URL` is a `str`, so it can be passed anywhere a string URL is accepted. + Appending a segment returns a new `URL`: + + ```python + srv.url / "users" / "123" + ``` + + The `scheme`, `host`, `port` and `path` attributes are parsed on first + access. + """ + + # `cached_property` stores the parsed URL in the instance dictionary. + __slots__ = ("__dict__",) + + @cached_property + def _parts(self) -> SplitResult: + return urlsplit(self) + + @property + def scheme(self) -> str: + """ + URL scheme, such as `http`. + """ + return self._parts.scheme + + @property + def host(self) -> str | None: + """ + Lowercased host name, without brackets for IPv6 addresses. + """ + return self._parts.hostname + + @property + def port(self) -> int | None: + """ + Port number. + + Defaults to 80 for `http` and 443 for `https` when the URL gives no + port, and is `None` for other schemes. + """ + port = self._parts.port + if port is not None: + return port + return DEFAULT_PORTS.get(self._parts.scheme) + + @property + def path(self) -> str: + """ + URL path, `/` when empty. + """ + return self._parts.path or "/" + + def __truediv__(self, segment: str | object) -> URL: + """ + Append a path segment. + + The segment is percent-encoded. The query and fragment are dropped. + + Args: + segment: + Path segment to append. It may contain `/`, but must not start + with one. + + Returns: + A new URL with the segment appended. + + Raises: + ValueError: + If the segment starts with `/`. + """ + if not isinstance(segment, str): + return NotImplemented + if segment.startswith("/"): + msg = f"Cannot append a path starting with '/': {segment!r}" + raise ValueError(msg) + + path = self._parts.path + if segment: + path = path.removesuffix("/") + "/" + quote(segment, safe=_PATH_SAFE) + return URL(urlunsplit(self._parts._replace(path=path, query="", fragment=""))) + + def __repr__(self) -> str: + """ + Representation distinguishing a `URL` from a plain string. + """ + return f"URL({str.__repr__(self)})" diff --git a/src/pact/_util.py b/src/pact/_util.py index 680d1e380..e7243309f 100644 --- a/src/pact/_util.py +++ b/src/pact/_util.py @@ -15,9 +15,12 @@ import sys import warnings from functools import partial -from importlib.metadata import PackageNotFoundError, version from inspect import Parameter, _ParameterKind from typing import TYPE_CHECKING, TypeVar +from urllib.parse import unquote, urlsplit, urlunsplit + +from pact.__version__ import __version__ +from pact._url import DEFAULT_PORTS if TYPE_CHECKING: from collections.abc import Callable, Mapping @@ -25,6 +28,34 @@ logger = logging.getLogger(__name__) +def strip_url_credentials(url: str) -> tuple[str, str | None, str | None]: + """ + Remove the username and password from a URL. + + The host is lowercased, and an explicit port 80 for `http` or 443 for + `https` is dropped. The path and query pass through unencoded. + + Args: + url: + The URL, which may contain credentials. + + Returns: + The URL without credentials, followed by the percent-decoded username + and password, each `None` if absent. + """ + parts = urlsplit(url) + username = unquote(parts.username) if parts.username else None + password = unquote(parts.password) if parts.password else None + + netloc = parts.hostname or "" + if ":" in netloc: + netloc = f"[{netloc}]" + if parts.port is not None and parts.port != DEFAULT_PORTS.get(parts.scheme): + netloc = f"{netloc}:{parts.port}" + + return urlunsplit(parts._replace(netloc=netloc)), username, password + + def user_agent() -> str: """ Build the `User-Agent` header value for outbound HTTP requests. @@ -35,10 +66,8 @@ def user_agent() -> str: Returns: The formatted `User-Agent` string. """ - try: - pact_ver = version("pact-python") - except PackageNotFoundError: - pact_ver = "unknown" + # Deferred as `importlib.metadata` is slow to import and rarely needed. + from importlib.metadata import PackageNotFoundError, version # noqa: PLC0415 try: requests_ver = version("requests") @@ -46,7 +75,7 @@ def user_agent() -> str: requests_ver = "unknown" py_ver = "{}.{}.{}".format(*sys.version_info[:3]) - return f"pact-python/{pact_ver} requests/{requests_ver} python/{py_ver}" + return f"pact-python/{__version__} requests/{requests_ver} python/{py_ver}" _PYTHON_FORMAT_TO_JAVA_DATETIME = { diff --git a/src/pact/pact.py b/src/pact/pact.py index db0c08755..8aba8db11 100644 --- a/src/pact/pact.py +++ b/src/pact/pact.py @@ -73,9 +73,8 @@ overload, ) -from yarl import URL - import pact_ffi +from pact._url import URL from pact.error import ( InteractionVerificationError, Mismatch, @@ -704,6 +703,9 @@ def transport(self) -> str: def url(self) -> URL: """ Base URL for the server. + + A [`URL`][types.URL] is a `str`, so it can be passed directly to + an HTTP client. Path segments can be appended with `/`. """ return URL(str(self)) diff --git a/src/pact/types.py b/src/pact/types.py index bc2f021f4..6bf5d2f44 100644 --- a/src/pact/types.py +++ b/src/pact/types.py @@ -10,7 +10,7 @@ from typing import Any, Literal, TypeAlias, TypedDict -from yarl import URL +from pact._url import URL Matchable: TypeAlias = Any """ @@ -132,7 +132,7 @@ class StateHandlerArgs(TypedDict, total=False): """ -StateHandlerUrl: TypeAlias = str | URL +StateHandlerUrl: TypeAlias = str """ State handler URL signature. @@ -173,3 +173,17 @@ def f(v: str | Unset = UNSET): This is used to provide a default value for an optional argument that needs to differentiate between a `None` value and an unset value. """ + + +__all__ = [ + "UNSET", + "URL", + "GeneratorType", + "Matchable", + "MatcherType", + "Message", + "MessageProducerArgs", + "StateHandlerArgs", + "StateHandlerUrl", + "Unset", +] diff --git a/src/pact/types.pyi b/src/pact/types.pyi index 7f4fc61aa..231dacf67 100644 --- a/src/pact/types.pyi +++ b/src/pact/types.pyi @@ -12,7 +12,8 @@ from fractions import Fraction from typing import Any, Literal, TypeAlias, TypedDict from pydantic import BaseModel -from yarl import URL + +from pact._url import URL as URL _BaseMatchable: TypeAlias = ( int | float | complex | bool | str | bytes | bytearray | memoryview | None @@ -130,7 +131,7 @@ class StateHandlerArgs(TypedDict, total=False): action: Literal["setup", "teardown"] parameters: dict[str, Any] | None -StateHandlerUrl: TypeAlias = str | URL +StateHandlerUrl: TypeAlias = str """ State handler URL signature. @@ -141,3 +142,16 @@ provide a URL endpoint to which the request should be made. class Unset: ... UNSET = Unset() + +__all__ = [ + "UNSET", + "URL", + "GeneratorType", + "Matchable", + "MatcherType", + "Message", + "MessageProducerArgs", + "StateHandlerArgs", + "StateHandlerUrl", + "Unset", +] diff --git a/src/pact/verifier.py b/src/pact/verifier.py index cd01fef8a..f793c34db 100644 --- a/src/pact/verifier.py +++ b/src/pact/verifier.py @@ -82,18 +82,15 @@ from datetime import date from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, TypedDict, overload - -from yarl import URL +from urllib.parse import unquote, urlsplit import pact_ffi -from pact._server import MessageProducer, StateCallback -from pact._util import apply_args +from pact._util import apply_args, strip_url_credentials from pact.types import ( UNSET, Message, MessageProducerArgs, StateHandlerArgs, - StateHandlerUrl, Unset, ) @@ -107,6 +104,11 @@ from collections.abc import Iterable from typing import Self + # Imported at runtime only by the handler methods: `pact._server` pulls in + # `http.server`, which most verifications never need. + from pact._server import MessageProducer, StateCallback + from pact.types import StateHandlerUrl + logger = logging.getLogger(__name__) @@ -274,7 +276,7 @@ def __repr__(self) -> str: def add_transport( self, *, - url: str | URL | None = None, + url: str | None = None, protocol: str | None = None, port: int | None = None, path: str | None = None, @@ -350,16 +352,16 @@ def add_transport( raise ValueError(msg) if url: - url = URL(url) - if url.host != self._host: - msg = f"Host mismatch: {url.host} != {self._host}" + parts = urlsplit(str(url)) + if parts.hostname != self._host: + msg = f"Host mismatch: {parts.hostname} != {self._host}" raise ValueError(msg) - protocol = url.scheme + protocol = parts.scheme if protocol == "https": protocol = "http" - port = url.port - path = url.path - scheme = url.scheme + port = parts.port + path = unquote(parts.path) or "/" + scheme = parts.scheme return self.add_transport( protocol=protocol, port=port, @@ -450,6 +452,8 @@ def message_handler( is raised within the message relay server and surfaces as a failed interaction. """ + from pact._server import MessageProducer # noqa: PLC0415 + logger.debug( "Setting message handler for verifier", extra={ @@ -667,7 +671,7 @@ def state_handler( TypeError: If the handler type is invalid. """ - if isinstance(handler, StateHandlerUrl): + if isinstance(handler, str): if body is None: msg = "The `body` parameter must be a boolean when providing a URL" raise ValueError(msg) @@ -770,6 +774,8 @@ def _state_handler_dict( TypeError: If any value in the dictionary is not callable. """ + from pact._server import StateCallback # noqa: PLC0415 + if any(not callable(f) for f in handler.values()): msg = "All values in the dictionary must be callable" raise TypeError(msg) @@ -845,6 +851,8 @@ def _state_handler_function( TypeError: If the handler is not callable. """ + from pact._server import StateCallback # noqa: PLC0415 + logger.debug( "Setting function state handler for verifier", extra={ @@ -1032,21 +1040,21 @@ def follow_redirects(self, follow: bool) -> Self: # noqa: FBT001 @overload def add_source( self, - source: str | URL, + source: str, *, username: str | None = None, password: str | None = None, ) -> Self: ... @overload - def add_source(self, source: str | URL, *, token: str | None = None) -> Self: ... + def add_source(self, source: str, *, token: str | None = None) -> Self: ... @overload def add_source(self, source: str | Path) -> Self: ... def add_source( self, - source: str | Path | URL, + source: str | Path, *, username: str | None = None, password: str | None = None, @@ -1064,6 +1072,7 @@ def add_source( - A local file path to a Pact file. - A local file path to a directory containing Pact files. + - A `file://` URL to a local Pact file or directory. - A URL to a Pact file. If using a URL, the `username` and `password` parameters can be @@ -1087,31 +1096,30 @@ def add_source( Raises: ValueError: - If the source scheme is invalid. + If a local source is not a file or directory, or if + credentials are given both in the URL and as arguments. + + TypeError: + If the source type is invalid. """ if isinstance(source, Path): return self._add_source_local(source) - if isinstance(source, URL): - if source.scheme == "file": - return self._add_source_local(source.path) + if not isinstance(source, str): + msg = f"Invalid source type: {type(source).__name__}" + raise TypeError(msg) - if source.scheme in ("http", "https"): - return self._add_source_remote( - source, - username=username, - password=password, - token=token, - ) + if source.startswith("file://"): + # `urllib.request` is slow to import, and only `file://` sources need it. + from urllib.request import url2pathname # noqa: PLC0415 - msg = f"Invalid source scheme: {source.scheme}" - raise ValueError(msg) + return self._add_source_local(url2pathname(urlsplit(source).path)) # Strings are ambiguous, so we need identify them as either local or # remote. if "://" in source: return self._add_source_remote( - URL(source), + source, username=username, password=password, token=token, @@ -1149,7 +1157,7 @@ def _add_source_local(self, source: str | Path) -> Self: def _add_source_remote( self, - url: str | URL, + url: str, *, username: str | None = None, password: str | None = None, @@ -1184,17 +1192,17 @@ def _add_source_remote( ValueError: If mutually exclusive authentication parameters are provided. """ - url = URL(url) + bare_url, url_username, url_password = strip_url_credentials(str(url)) - if url.user and username: + if url_username and username: msg = "Cannot specify both `username` and a username in the URL" raise ValueError(msg) - username = url.user or username + username = url_username or username - if url.password and password: + if url_password and password: msg = "Cannot specify both `password` and a password in the URL" raise ValueError(msg) - password = url.password or password + password = url_password or password if token and (username or password): msg = "Cannot specify both `token` and `username`/`password`" @@ -1202,7 +1210,7 @@ def _add_source_remote( pact_ffi.verifier_url_source( self._handle, - str(url.with_user(None).with_password(None)), + bare_url, username, password, token, @@ -1212,7 +1220,7 @@ def _add_source_remote( @overload def broker_source( self, - url: str | URL | Unset = UNSET, + url: str | Unset = UNSET, *, username: str | Unset = UNSET, password: str | Unset = UNSET, @@ -1223,7 +1231,7 @@ def broker_source( @overload def broker_source( self, - url: str | URL | Unset | None = UNSET, + url: str | Unset | None = UNSET, *, token: str | Unset | None = UNSET, selector: Literal[False] = False, @@ -1233,7 +1241,7 @@ def broker_source( @overload def broker_source( self, - url: str | URL | Unset | None = UNSET, + url: str | Unset | None = UNSET, *, username: str | Unset | None = UNSET, password: str | Unset | None = UNSET, @@ -1244,7 +1252,7 @@ def broker_source( @overload def broker_source( self, - url: str | URL | Unset | None = UNSET, + url: str | Unset | None = UNSET, *, token: str | Unset | None = UNSET, selector: Literal[True], @@ -1253,7 +1261,7 @@ def broker_source( def broker_source( # noqa: PLR0913 self, - url: str | URL | Unset | None = UNSET, + url: str | Unset | None = UNSET, *, username: str | Unset | None = UNSET, password: str | Unset | None = UNSET, @@ -1327,15 +1335,15 @@ def maybe_var(v: Any | Unset, env: str) -> str | None: # noqa: ANN401 if not url: msg = "A broker URL must be provided" raise ValueError(msg) - url = URL(url) + bare_url, url_username, url_password = strip_url_credentials(str(url)) username = maybe_var(username, "PACT_BROKER_USERNAME") - if url.user and username: + if url_username and username: msg = "Cannot specify both `username` and a username in the URL" raise ValueError(msg) password = maybe_var(password, "PACT_BROKER_PASSWORD") - if url.password and password: + if url_password and password: msg = "Cannot specify both `password` and a password in the URL" raise ValueError(msg) @@ -1347,7 +1355,7 @@ def maybe_var(v: Any | Unset, env: str) -> str | None: # noqa: ANN401 if selector: return BrokerSelectorBuilder( self, - str(url.with_user(None).with_password(None)), + bare_url, username, password, token, @@ -1355,7 +1363,7 @@ def maybe_var(v: Any | Unset, env: str) -> str | None: # noqa: ANN401 self._broker_source_hook = lambda: pact_ffi.verifier_broker_source( self._handle, - str(url.with_user(None).with_password(None)), + bare_url, username, password, token, diff --git a/tests/compatibility_suite/conftest.py b/tests/compatibility_suite/conftest.py index 73b61d6c1..2d30e669a 100644 --- a/tests/compatibility_suite/conftest.py +++ b/tests/compatibility_suite/conftest.py @@ -14,7 +14,6 @@ import pytest from testcontainers.compose import DockerCompose # type: ignore[import-untyped] -from yarl import URL from pact.verifier import Verifier @@ -54,7 +53,7 @@ def verifier() -> Verifier: @pytest.fixture(scope="session") -def broker_url(request: pytest.FixtureRequest) -> Generator[URL, Any, None]: +def broker_url(request: pytest.FixtureRequest) -> Generator[str, Any, None]: """ Yield the Pact Broker URL, starting a container when required. @@ -74,7 +73,7 @@ def broker_url(request: pytest.FixtureRequest) -> Generator[URL, Any, None]: # If we have been given a broker URL, there's nothing more to do here and we # can return early. if broker_url: - yield URL(broker_url) + yield broker_url return with DockerCompose( @@ -82,5 +81,5 @@ def broker_url(request: pytest.FixtureRequest) -> Generator[URL, Any, None]: compose_file_name="pact-broker.yml", pull=True, ) as _: - yield URL("http://pactbroker:pactbroker@localhost:9292") + yield "http://pactbroker:pactbroker@localhost:9292" return diff --git a/tests/compatibility_suite/util/consumer.py b/tests/compatibility_suite/util/consumer.py index 673ebbca9..e8e242f37 100644 --- a/tests/compatibility_suite/util/consumer.py +++ b/tests/compatibility_suite/util/consumer.py @@ -8,11 +8,11 @@ import logging import re from typing import TYPE_CHECKING, Any, TypeGuard +from urllib.parse import parse_qsl import pytest import requests from pytest_bdd import given, parsers, then, when -from yarl import URL from pact import Pact from pact.error import ( @@ -233,9 +233,9 @@ def _( return requests.request( definition.method, - str(srv.url.with_path(definition.path)), + str(srv.url / definition.path.lstrip("/")), params=( - URL.build(query_string=definition.query).query + parse_qsl(definition.query, keep_blank_values=True) if definition.query else None ), @@ -287,9 +287,9 @@ def _( return requests.request( definition.method, - str(srv.url.with_path(definition.path)), + str(srv.url / definition.path.lstrip("/")), params=( - URL.build(query_string=definition.query).query + parse_qsl(definition.query, keep_blank_values=True) if definition.query else None ), diff --git a/tests/compatibility_suite/util/interaction_definition.py b/tests/compatibility_suite/util/interaction_definition.py index 65b932d0b..4e95a1f6e 100644 --- a/tests/compatibility_suite/util/interaction_definition.py +++ b/tests/compatibility_suite/util/interaction_definition.py @@ -14,10 +14,10 @@ import typing import warnings from typing import Any, Literal +from urllib.parse import parse_qsl from xml.etree import ElementTree as ET from multidict import MultiDict -from yarl import URL from pact.interaction import HttpInteraction, Interaction from tests.compatibility_suite.util import ( @@ -39,6 +39,16 @@ logger = logging.getLogger(__name__) +def _parse_query(query: str) -> dict[str, list[str]]: + """ + Group query parameters by name, keeping the order of repeated names. + """ + grouped: dict[str, list[str]] = {} + for key, value in parse_qsl(query, keep_blank_values=True): + grouped.setdefault(key, []).append(value) + return grouped + + class InteractionBody: """ Interaction body. @@ -557,9 +567,9 @@ def add_to_pact( # noqa: C901, PLR0912, PLR0915 assert isinstance(interaction, HttpInteraction), ( "Query parameters require an HTTP interaction" ) - query = URL.build(query_string=self.query).query - logger.info("with_query_parameters(%r)", query.items()) - interaction.with_query_parameters(query.items()) + query = parse_qsl(self.query, keep_blank_values=True) + logger.info("with_query_parameters(%r)", query) + interaction.with_query_parameters(query) if self.headers: assert isinstance(interaction, HttpInteraction), ( @@ -696,10 +706,10 @@ def _handle_request_http(self, request: SimpleHTTPRequestHandler) -> None: # no # We expect an exact match of the query parameters (unlike the headers) if self.query: logger.info("Checking request query parameters") - expected_query = URL.build(query_string=self.query).query - request_query = URL.build( - query_string=request.path.split("?")[1] if "?" in request.path else "" - ).query + expected_query = _parse_query(self.query) + request_query = _parse_query( + request.path.split("?")[1] if "?" in request.path else "" + ) if (expected_keys := set(expected_query.keys())) != ( request_keys := set(request_query.keys()) ): @@ -712,8 +722,8 @@ def _handle_request_http(self, request: SimpleHTTPRequestHandler) -> None: # no return for k in expected_query: - if (request_vals := request_query.getall(k)) != ( - expected_vals := expected_query.getall(k) + if (request_vals := request_query[k]) != ( + expected_vals := expected_query[k] ): logger.error( "Query parameter mismatch: %s != %s", diff --git a/tests/compatibility_suite/util/provider.py b/tests/compatibility_suite/util/provider.py index 39c6599cc..32a87ea1d 100644 --- a/tests/compatibility_suite/util/provider.py +++ b/tests/compatibility_suite/util/provider.py @@ -32,12 +32,12 @@ import requests from multidict import CIMultiDict from pytest_bdd import given, parsers, then, when -from yarl import URL from pact import __version__ from pact._server import MessageProducer -from pact._util import find_free_port +from pact._util import find_free_port, strip_url_credentials from pact.pact import Pact +from pact.types import URL from tests.compatibility_suite.util import ( parse_headers, parse_horizontal_table, @@ -325,7 +325,7 @@ class PactBroker: def __init__( self, - broker_url: URL, + broker_url: str, *, username: str | None = None, password: str | None = None, @@ -335,9 +335,11 @@ def __init__( """ Instantiate a new Pact Broker interface. """ - self.url = broker_url - self.username = broker_url.user or username - self.password = broker_url.password or password + self.url = URL(broker_url) + bare_url, url_username, url_password = strip_url_credentials(broker_url) + self.bare_url = bare_url + self.username = url_username or username + self.password = url_password or password self.provider = provider self.consumer = consumer @@ -383,7 +385,7 @@ def publish(self, directory: Path | str, version: str | None = None) -> None: "publish", str(directory), "--broker-base-url", - str(self.url.with_user(None).with_password(None)), + self.bare_url, ] if self.username: cmd.extend(["--broker-username", self.username]) @@ -812,7 +814,7 @@ def a_pact_file_for_interaction_is_to_be_verified_from_a_pact_broker( ) def _( interaction_definitions: dict[int, InteractionDefinition], - broker_url: URL, + broker_url: str, verifier: Verifier, interaction: int, tmp_path: Path, diff --git a/tests/interaction/test_http_interaction.py b/tests/interaction/test_http_interaction.py index fe60e24ca..50e6fe686 100644 --- a/tests/interaction/test_http_interaction.py +++ b/tests/interaction/test_http_interaction.py @@ -328,10 +328,11 @@ async def test_with_query_parameter_request( .will_respond_with(200) ) 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: - assert resp.status == 200 + async with ( + aiohttp.ClientSession(srv.url) as session, + session.request("GET", "/", params=query) as resp, + ): + assert resp.status == 200 @pytest.mark.asyncio @@ -346,10 +347,11 @@ async def test_with_query_parameter_with_matcher( .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 + async with ( + aiohttp.ClientSession(srv.url) as session, + session.request("GET", "/", params=[("test", "true")]) as resp, + ): + assert resp.status == 200 @pytest.mark.asyncio @@ -362,10 +364,11 @@ async def test_with_query_parameter_dict(pact: Pact) -> None: .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 + async with ( + aiohttp.ClientSession(srv.url) as session, + session.request("GET", "/", params={"test": "true", "foo": "bar"}) as resp, + ): + assert resp.status == 200 @pytest.mark.asyncio @@ -378,10 +381,11 @@ async def test_with_query_parameter_tuple_list(pact: Pact) -> None: .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 + async with ( + aiohttp.ClientSession(srv.url) as session, + session.request("GET", "/", params={"test": "true", "foo": "bar"}) as resp, + ): + assert resp.status == 200 @pytest.mark.parametrize( diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 000000000..2a67b1321 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,41 @@ +""" +Tests for the import-time footprint of `pact`. +""" + +from __future__ import annotations + +import subprocess +import sys + +import pytest + + +@pytest.mark.parametrize( + "module", + [ + pytest.param("http.server", id="http.server"), + pytest.param("pact._server", id="pact._server"), + pytest.param("yarl", id="yarl"), + ], +) +def test_import_does_not_load(module: str) -> None: + """ + Check that `import pact` defers modules only some code paths need. + + A fresh interpreter is used, as the test session has already imported + most modules. + """ + code = f"import sys, pact; print({module!r} in sys.modules)" + result = subprocess.run( # noqa: S603 + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=True, + ) + assert result.stdout.strip() == "False", f"`import pact` loaded {module}" + + +def test_state_handler_url_alias() -> None: + from pact.types import StateHandlerUrl # noqa: PLC0415 + + assert StateHandlerUrl is str diff --git a/tests/test_match.py b/tests/test_match.py index 0ed9de7a9..1e8a9ac53 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -16,10 +16,10 @@ import requests from flask import Flask, Response, make_response -from yarl import URL import pact._util from pact import Pact, Verifier, generate, match +from pact.types import URL if TYPE_CHECKING: from collections.abc import Generator diff --git a/tests/test_pact.py b/tests/test_pact.py index ab8c2ec13..68dc2e4e5 100644 --- a/tests/test_pact.py +++ b/tests/test_pact.py @@ -11,6 +11,7 @@ import pytest from pact import Pact +from pact.types import URL from pact_ffi import PactSpecification if TYPE_CHECKING: @@ -49,6 +50,8 @@ def test_serve(pact: Pact) -> None: assert srv.port > 0 assert srv.host == "localhost" assert str(srv).startswith("http://localhost") + assert isinstance(srv.url, URL) + assert isinstance(srv / "foo", URL) assert srv.url.scheme == "http" assert srv.url.host == "localhost" assert srv.url.path == "/" diff --git a/tests/test_url.py b/tests/test_url.py new file mode 100644 index 000000000..8c7e077f0 --- /dev/null +++ b/tests/test_url.py @@ -0,0 +1,92 @@ +""" +Tests for `pact.types.URL`. +""" + +from __future__ import annotations + +import pytest + +from pact.types import URL + + +@pytest.mark.parametrize( + ("base", "segments", "expected"), + [ + pytest.param("http://h:1", ["a"], "http://h:1/a", id="single"), + pytest.param("http://h:1", ["a", "b"], "http://h:1/a/b", id="chained"), + pytest.param("http://h:1", ["a/b"], "http://h:1/a/b", id="slash-in-segment"), + pytest.param("http://h/a", ["b"], "http://h/a/b", id="base-without-slash"), + pytest.param("http://h/a/", ["b"], "http://h/a/b", id="base-with-slash"), + pytest.param("http://h:1", ["a b"], "http://h:1/a%20b", id="space"), + pytest.param("http://h:1", ["a%20b"], "http://h:1/a%2520b", id="percent"), + pytest.param("http://h:1", ["a?b"], "http://h:1/a%3Fb", id="question-mark"), + pytest.param("http://h:1", ["a+b"], "http://h:1/a+b", id="plus"), + pytest.param("http://h:1", [":@"], "http://h:1/:@", id="colon-at"), + pytest.param("http://h:1/a", [""], "http://h:1/a", id="empty-segment"), + pytest.param("http://h/a?x=1#f", ["b"], "http://h/a/b", id="drop-query"), + pytest.param("http://u:p@h/", ["b"], "http://u:p@h/b", id="credentials"), + ], +) +def test_truediv(base: str, segments: list[str], expected: str) -> None: + url = URL(base) + for segment in segments: + url /= segment + assert isinstance(url, URL) + assert url == expected + + +def test_truediv_rejects_leading_slash() -> None: + with pytest.raises(ValueError, match="starting with '/'"): + _ = URL("http://h") / "/a" + + +def test_truediv_rejects_non_str() -> None: + with pytest.raises(TypeError): + _ = URL("http://h") / 1 + + +@pytest.mark.parametrize( + ("url", "scheme", "host", "port", "path"), + [ + pytest.param( + "http://localhost:1234", "http", "localhost", 1234, "/", id="port" + ), + pytest.param( + "http://example.com/a", "http", "example.com", 80, "/a", id="http" + ), + pytest.param( + "https://Example.com/", "https", "example.com", 443, "/", id="https" + ), + pytest.param("grpc://h/x", "grpc", "h", None, "/x", id="unknown-scheme"), + pytest.param("http://[::1]:8080/", "http", "::1", 8080, "/", id="ipv6"), + ], +) +def test_attributes( + url: str, + scheme: str, + host: str, + port: int | None, + path: str, +) -> None: + parsed = URL(url) + assert parsed.scheme == scheme + assert parsed.host == host + assert parsed.port == port + assert parsed.path == path + + +def test_str_behaviour() -> None: + url = URL("http://h:1") + assert isinstance(url, str) + assert url == "http://h:1" + assert f"{url}/a" == "http://h:1/a" + assert str(url) == "http://h:1" + assert type(str(url)) is str + assert repr(url) == "URL('http://h:1')" + + +def test_hash_matches_str() -> None: + url = URL("http://h:1") + mapping: dict[str, int] = {url: 1} + assert mapping["http://h:1"] == 1 + assert {url, "http://h:1"} == {"http://h:1"} diff --git a/tests/test_util.py b/tests/test_util.py index d35cbf4b7..d3f073892 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -8,12 +8,68 @@ import pytest -from pact._util import apply_args, strftime_to_simple_date_format +from pact._util import ( + apply_args, + strftime_to_simple_date_format, + strip_url_credentials, +) if TYPE_CHECKING: from collections.abc import Callable +@pytest.mark.parametrize( + ("url", "expected"), + [ + pytest.param( + "http://localhost:8080", + ("http://localhost:8080", None, None), + id="no-credentials", + ), + pytest.param( + "http://user@broker:9292", + ("http://broker:9292", "user", None), + id="username", + ), + pytest.param( + "http://user:pass@broker:9292/", + ("http://broker:9292/", "user", "pass"), + id="username-password", + ), + pytest.param( + "http://:pass@broker/", + ("http://broker/", None, "pass"), + id="password-only", + ), + pytest.param( + "https://us%40er:p%3Ass@broker.example.com/pacts?latest=true", + ("https://broker.example.com/pacts?latest=true", "us@er", "p:ss"), + id="percent-encoded", + ), + pytest.param( + "http://LocalHost:8000/api/v1", + ("http://localhost:8000/api/v1", None, None), + id="uppercase-host", + ), + pytest.param( + "https://example.com:443/a%20b/c", + ("https://example.com/a%20b/c", None, None), + id="default-port", + ), + pytest.param( + "http://user:pass@[::1]:8080/", + ("http://[::1]:8080/", "user", "pass"), + id="ipv6", + ), + ], +) +def test_strip_url_credentials( + url: str, + expected: tuple[str, str | None, str | None], +) -> None: + assert strip_url_credentials(url) == expected + + 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" diff --git a/tests/test_verifier.py b/tests/test_verifier.py index 196defbe4..f52334910 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import nturl2path import re from contextlib import nullcontext from pathlib import Path @@ -17,7 +18,7 @@ import pytest -from pact.types import Message +from pact.types import URL, Message from pact.verifier import Verifier ASSETS_DIR = Path(__file__).parent / "assets" @@ -42,6 +43,136 @@ def test_set_provider_info(verifier: Verifier) -> None: verifier.verify() +@pytest.mark.parametrize( + ("url", "expected"), + [ + pytest.param( + "http://localhost:8888/api", + {"transport": "http", "port": 8888, "path": "/api", "scheme": "http"}, + id="http", + ), + pytest.param( + "https://localhost", + {"transport": "http", "port": 443, "path": "/", "scheme": "https"}, + id="https-default-port", + ), + pytest.param( + URL("http://LocalHost/a%20b"), + {"transport": "http", "port": 80, "path": "/a b", "scheme": "http"}, + id="pact-url", + ), + pytest.param( + "grpc://localhost:50051", + {"transport": "grpc", "port": 50051, "path": "/", "scheme": "grpc"}, + id="grpc", + ), + ], +) +def test_add_transport_from_url( + verifier: Verifier, + url: str | URL, + expected: dict[str, Any], +) -> None: + verifier.add_transport(url=url) + assert verifier._transports[-1] == expected # noqa: SLF001 + + +def test_add_transport_host_mismatch(verifier: Verifier) -> None: + with pytest.raises(ValueError, match="Host mismatch"): + verifier.add_transport(url="http://example.com:8080") + + +@pytest.mark.parametrize("method", ["add_source", "broker_source"]) +@pytest.mark.parametrize( + ("url", "credential"), + [ + pytest.param("http://user@localhost:8080/pact.json", "username", id="username"), + pytest.param( + "http://user:pass@localhost:8080/pact.json", + "password", + id="password", + ), + pytest.param( + URL("http://user@localhost:8080/pact.json"), + "username", + id="pact-url-username", + ), + ], +) +def test_rejects_credentials_in_url_and_arguments( + verifier: Verifier, + method: str, + url: str | URL, + credential: str, +) -> None: + with pytest.raises(ValueError, match=f"both `{credential}` and a {credential}"): + getattr(verifier, method)(url, **{credential: "other"}) + + +class _StrOnly: + def __init__(self, value: str) -> None: + self._value = value + + def __str__(self) -> str: + return self._value + + +def test_add_source_file_url_missing(verifier: Verifier) -> None: + with pytest.raises(ValueError, match="Invalid source"): + verifier.add_source("file:///nonexistent/pact.json") + + +def test_add_source_file_url(verifier: Verifier, tmp_path: Path) -> None: + pact_file = tmp_path / "a pact.json" + pact_file.write_text("{}") + with patch("pact_ffi.verifier_add_file_source") as mock_add: + verifier.add_source(pact_file.as_uri()) + mock_add.assert_called_once_with(verifier._handle, str(pact_file)) # noqa: SLF001 + + +def test_add_source_windows_file_url(verifier: Verifier) -> None: + # `nturl2path` is the Windows implementation of `url2pathname`. + with ( + patch("urllib.request.url2pathname", nturl2path.url2pathname), + patch.object(verifier, "_add_source_local") as mock_local, + ): + verifier.add_source("file:///C:/pacts/a%20pact.json") + mock_local.assert_called_once_with("C:\\pacts\\a pact.json") + + +def test_add_source_windows_path_is_local(verifier: Verifier) -> None: + with patch.object(verifier, "_add_source_local") as mock_local: + verifier.add_source("C:\\pacts") + mock_local.assert_called_once_with("C:\\pacts") + + +@pytest.mark.parametrize( + "source", + [ + pytest.param(42, id="int"), + pytest.param(b"http://localhost/pact.json", id="bytes"), + pytest.param(_StrOnly("http://localhost/pact.json"), id="custom-str"), + ], +) +def test_add_source_invalid_type(verifier: Verifier, source: object) -> None: + with pytest.raises(TypeError, match="Invalid source type"): + verifier.add_source(source) # type: ignore[call-overload] + + +@pytest.mark.parametrize( + "handler", + [ + pytest.param(42, id="int"), + pytest.param(b"http://localhost", id="bytes"), + pytest.param(Path("pacts/state"), id="path"), + pytest.param(_StrOnly("http://localhost/_pact/state"), id="custom-str"), + ], +) +def test_state_handler_invalid_type(verifier: Verifier, handler: object) -> None: + with pytest.raises(TypeError, match="Invalid handler type"): + verifier.state_handler(handler, body=True) # type: ignore[call-overload] + + def test_add_provider_transport(verifier: Verifier) -> None: # HTTP verifier.add_transport(