Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adding matcher POC #761

Merged
merged 43 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e77b562
adding matcher POC
valkolovos Aug 13, 2024
18d0e12
Matcher / generator implementations and examples
valkolovos Sep 12, 2024
a843538
linting and python version fixes
valkolovos Sep 13, 2024
1ca2832
linting and formatting fixes
valkolovos Sep 13, 2024
5262e13
fix: missing typing arguments
JP-Ellis Sep 19, 2024
834c73b
fix: incompatible override
JP-Ellis Sep 19, 2024
2902425
fix: kwargs typing
JP-Ellis Sep 19, 2024
bedd5a8
refactor: prefer `|` over Optional and Union
JP-Ellis Sep 19, 2024
7830611
chore: prefer ABC over ABCMeta
JP-Ellis Sep 20, 2024
ff434a0
docs: add matcher module preamble
JP-Ellis Sep 20, 2024
85d2fea
refactor: rename matchers to match
JP-Ellis Sep 20, 2024
511c41a
chore: re-organise match module
JP-Ellis Sep 20, 2024
4cd67f5
fix recursive typing issues
valkolovos Sep 20, 2024
fe4d4af
fix issues importing MatcherEncoder
valkolovos Sep 20, 2024
547fbe1
fixing typing issues for python3.9
valkolovos Sep 20, 2024
0dfc73c
refactor: split types into stub
JP-Ellis Sep 26, 2024
2f33caf
feat: add matchable typevar
JP-Ellis Sep 26, 2024
1df0319
refactor: matcher
JP-Ellis Sep 26, 2024
370aa3a
chore: split stdlib and 3rd party types
JP-Ellis Sep 30, 2024
b44a238
docs: add module docstring
JP-Ellis Sep 30, 2024
9662008
refactor: rename generators to generate
JP-Ellis Sep 30, 2024
57aeb3b
feat: add strftime to java date format converter
JP-Ellis Sep 30, 2024
a530f3b
chore: silence a few mypy complaints
JP-Ellis Sep 30, 2024
fa3247f
feat: improve match module
JP-Ellis Sep 30, 2024
77eeeab
chore: add pyi to editor config
JP-Ellis Sep 30, 2024
dc5acc2
chore: add test for full ISO 8601 date
JP-Ellis Oct 1, 2024
962f642
feat: add match aliases
JP-Ellis Oct 1, 2024
0856c4e
feat: add uuid matcher
JP-Ellis Oct 1, 2024
2de9b8f
refactor: generate module in style of match module
JP-Ellis Oct 1, 2024
f0307b9
refactor: create pact.v3.types module
JP-Ellis Oct 1, 2024
289fd5e
chore: minor improvements to match.matcher
JP-Ellis Oct 1, 2024
fabd01d
chore: align generator with matcher
JP-Ellis Oct 1, 2024
a0a3729
chore: remove MatchableT
JP-Ellis Oct 1, 2024
d8b1f3f
feat: add each key/value matchers
JP-Ellis Oct 1, 2024
a00267f
refactor: generators module
JP-Ellis Oct 1, 2024
5f03481
refactor: match module
JP-Ellis Oct 1, 2024
1f2fb0b
chore: get test to run again
JP-Ellis Oct 1, 2024
4950f50
feat: add ArrayContainsMatcher
JP-Ellis Oct 1, 2024
9814b3d
chore: add boolean alias
JP-Ellis Oct 1, 2024
1c6170c
chore: fix compatibility with Python <= 3.9
JP-Ellis Oct 2, 2024
ba8c3c8
fix: ensure matchers optionally use generators
JP-Ellis Oct 2, 2024
57d4405
chore: fix match tests
JP-Ellis Oct 2, 2024
a450209
chore: remove unnused generalisation
JP-Ellis Oct 2, 2024
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
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

[*.py]
[*.{py,pyi}]
indent_size = 4

[Makefile]
Expand Down
147 changes: 147 additions & 0 deletions examples/tests/v3/basic_flask_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""
Simple flask server for matcher example.
"""

import logging
import re
import signal
import subprocess
import sys
import time
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from random import randint, uniform
from threading import Thread
from typing import Generator, NoReturn

import requests
from yarl import URL

from flask import Flask, Response, make_response

logger = logging.getLogger(__name__)


@contextmanager
def start_provider() -> Generator[URL, None, None]: # noqa: C901
"""
Start the provider app.
"""
process = subprocess.Popen( # noqa: S603
[
sys.executable,
Path(__file__),
],
cwd=Path.cwd(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding="utf-8",
)

pattern = re.compile(r" \* Running on (?P<url>[^ ]+)")
while True:
if process.poll() is not None:
logger.error("Provider process exited with code %d", process.returncode)
logger.error(
"Provider stdout: %s", process.stdout.read() if process.stdout else ""
)
logger.error(
"Provider stderr: %s", process.stderr.read() if process.stderr else ""
)
msg = f"Provider process exited with code {process.returncode}"
raise RuntimeError(msg)
if (
process.stderr
and (line := process.stderr.readline())
and (match := pattern.match(line))
):
break
time.sleep(0.1)

url = URL(match.group("url"))
logger.debug("Provider started on %s", url)
for _ in range(50):
try:
response = requests.get(str(url / "_test" / "ping"), timeout=1)
assert response.text == "pong"
break
except (requests.RequestException, AssertionError):
time.sleep(0.1)
continue
else:
msg = "Failed to ping provider"
raise RuntimeError(msg)

def redirect() -> NoReturn:
while True:
if process.stdout:
while line := process.stdout.readline():
logger.debug("Provider stdout: %s", line.strip())
if process.stderr:
while line := process.stderr.readline():
logger.debug("Provider stderr: %s", line.strip())

thread = Thread(target=redirect, daemon=True)
thread.start()

try:
yield url
finally:
process.send_signal(signal.SIGINT)


if __name__ == "__main__":
app = Flask(__name__)

@app.route("/path/to/<test_id>")
def hello_world(test_id: int) -> Response:
random_regex_matches = "1-8 digits: 12345678, 1-8 random letters abcdefgh"
response = make_response({
"response": {
"id": test_id,
"regexMatches": "must end with 'hello world'",
"randomRegexMatches": random_regex_matches,
"integerMatches": test_id,
"decimalMatches": round(uniform(0, 9), 3), # noqa: S311
"booleanMatches": True,
"randomIntegerMatches": randint(1, 100), # noqa: S311
"randomDecimalMatches": round(uniform(0, 9), 1), # noqa: S311
"randomStringMatches": "hi there",
"includeMatches": "hello world",
"includeWithGeneratorMatches": "say 'hello world' for me",
"minMaxArrayMatches": [
round(uniform(0, 9), 1) # noqa: S311
for _ in range(randint(3, 5)) # noqa: S311
],
"arrayContainingMatches": [randint(1, 100), randint(1, 100)], # noqa: S311
"numbers": {
"intMatches": 42,
"floatMatches": 3.1415,
"intGeneratorMatches": randint(1, 100), # noqa: S311,
"decimalGeneratorMatches": round(uniform(10, 99), 2), # noqa: S311
},
"dateMatches": "1999-12-31",
"randomDateMatches": "1999-12-31",
"timeMatches": "12:34:56",
"timestampMatches": datetime.now().isoformat(), # noqa: DTZ005
"nullMatches": None,
"eachKeyMatches": {
"id_1": {
"name": "John Doe",
},
"id_2": {
"name": "Jane Doe",
},
},
}
})
response.headers["SpecialHeader"] = "Special: Hi"
return response

@app.get("/_test/ping")
def ping() -> str:
"""Simple ping endpoint for testing."""
return "pong"

app.run()
134 changes: 134 additions & 0 deletions examples/tests/v3/test_match.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""
Example test to show usage of matchers (and generators by extension).
"""

import re
from pathlib import Path

import requests

from examples.tests.v3.basic_flask_server import start_provider
from pact.v3 import Pact, Verifier, generate, match


def test_matchers() -> None:
pact_dir = Path(Path(__file__).parent.parent.parent / "pacts")
pact = Pact("consumer", "provider").with_specification("V4")
(
pact.upon_receiving("a request")
.given("a state", parameters={"providerStateArgument": "providerStateValue"})
.with_request("GET", match.regex("/path/to/100", regex=r"/path/to/\d{1,4}"))
.with_query_parameter(
"asOf",
match.like(
[match.date("2024-01-01", format="%Y-%m-%d")],
min=1,
max=1,
),
)
.will_respond_with(200)
.with_body({
"response": match.like(
{
"regexMatches": match.regex(
"must end with 'hello world'",
regex=r"^.*hello world'$",
),
"randomRegexMatches": match.regex(
regex=r"1-8 digits: \d{1,8}, 1-8 random letters \w{1,8}"
),
"integerMatches": match.int(42),
"decimalMatches": match.decimal(3.1415),
"randomIntegerMatches": match.int(min=1, max=100),
"randomDecimalMatches": match.decimal(precision=4),
"booleanMatches": match.bool(False),
"randomStringMatches": match.string(size=10),
"includeMatches": match.includes("world"),
"includeWithGeneratorMatches": match.includes(
"world",
generator=generate.regex(r"\d{1,8} (hello )?world \d+"),
),
"minMaxArrayMatches": match.each_like(
match.number(1.23, precision=2),
min=3,
max=5,
),
"arrayContainingMatches": match.array_containing([
match.int(1),
match.int(2),
]),
"numbers": {
"intMatches": match.number(42),
"floatMatches": match.number(3.1415),
"intGeneratorMatches": match.number(2, max=10),
"decimalGeneratorMatches": match.number(3.1415, precision=4),
},
"dateMatches": match.date("2024-01-01", format="%Y-%m-%d"),
"randomDateMatches": match.date(format="%Y-%m-%d"),
"timeMatches": match.time("12:34:56", format="%H:%M:%S"),
"timestampMatches": match.timestamp(
"2024-01-01T12:34:56.000000",
format="%Y-%m-%dT%H:%M:%S.%f",
),
"nullMatches": match.null(),
"eachKeyMatches": match.each_key_matches(
{
"id_1": match.each_value_matches(
{"name": match.string(size=30)},
rules=match.string("John Doe"),
)
},
rules=match.regex("id_1", regex=r"^id_\d+$"),
),
},
min=1,
)
})
.with_header(
"SpecialHeader", match.regex("Special: Foo", regex=r"^Special: \w+$")
)
)
with pact.serve() as mockserver:
response = requests.get(
f"{mockserver.url}/path/to/35?asOf=2020-05-13", timeout=5
)
response_data = response.json()
# when a value is passed to a matcher, that value should be returned
assert (
response_data["response"]["regexMatches"] == "must end with 'hello world'"
)
assert response_data["response"]["integerMatches"] == 42
assert response_data["response"]["booleanMatches"] is False
assert response_data["response"]["includeMatches"] == "world"
assert response_data["response"]["dateMatches"] == "2024-01-01"
assert response_data["response"]["timeMatches"] == "12:34:56"
assert (
response_data["response"]["timestampMatches"]
== "2024-01-01T12:34:56.000000"
)
assert response_data["response"]["arrayContainingMatches"] == [1, 2]
assert response_data["response"]["nullMatches"] is None
# when a value is not passed to a matcher, a value should be generated
random_regex_matcher = re.compile(
r"1-8 digits: \d{1,8}, 1-8 random letters \w{1,8}"
)
assert random_regex_matcher.match(
response_data["response"]["randomRegexMatches"]
)
random_integer = int(response_data["response"]["randomIntegerMatches"])
assert random_integer >= 1
assert random_integer <= 100
float(response_data["response"]["randomDecimalMatches"])
assert (
len(response_data["response"]["randomDecimalMatches"].replace(".", "")) == 4
)
assert len(response_data["response"]["randomStringMatches"]) == 10

pact.write_file(pact_dir, overwrite=True)
with start_provider() as url:
verifier = (
Verifier()
.set_info("My Provider", url=url)
.add_source(pact_dir / "consumer-provider.json")
)
verifier.verify()
Loading
Loading