Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
# Test outputs
examples/tests/pacts

# Version file generated at build time
src/pact/__version__.py

# Wheels from CIBuildWheel
wheelhouse/

Expand Down
2 changes: 1 addition & 1 deletion docs/consumer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 4 additions & 6 deletions examples/plugins/protobuf/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pact-python-ffi/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
src/pact_ffi/__version__.py
src/pact_ffi/data
src/pact_ffi/*.pyd
src/pact_ffi/*.so
Expand Down
10 changes: 10 additions & 0 deletions pact-python-ffi/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
10 changes: 0 additions & 10 deletions pact-python-ffi/src/pact_ffi/__version__.py

This file was deleted.

11 changes: 10 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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"
Expand Down
10 changes: 0 additions & 10 deletions src/pact/__version__.py

This file was deleted.

105 changes: 105 additions & 0 deletions src/pact/_url.py
Original file line number Diff line number Diff line change
@@ -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)})"
41 changes: 35 additions & 6 deletions src/pact/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,47 @@
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

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.
Expand All @@ -35,18 +66,16 @@ 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")
except PackageNotFoundError:
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 = {
Expand Down
6 changes: 4 additions & 2 deletions src/pact/pact.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,8 @@
overload,
)

from yarl import URL

import pact_ffi
from pact._url import URL
from pact.error import (
InteractionVerificationError,
Mismatch,
Expand Down Expand Up @@ -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))

Expand Down
18 changes: 16 additions & 2 deletions src/pact/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from typing import Any, Literal, TypeAlias, TypedDict

from yarl import URL
from pact._url import URL

Matchable: TypeAlias = Any
"""
Expand Down Expand Up @@ -132,7 +132,7 @@ class StateHandlerArgs(TypedDict, total=False):
"""


StateHandlerUrl: TypeAlias = str | URL
StateHandlerUrl: TypeAlias = str
"""
State handler URL signature.

Expand Down Expand Up @@ -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",
]
18 changes: 16 additions & 2 deletions src/pact/types.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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",
]
Loading
Loading