Skip to content

Commit 562e047

Browse files
Merge pull request #138 from pyasi/pyasi_add_matcher_regexes
Pyasi add Matcher support for common regex formats
2 parents 48f0f51 + db39d87 commit 562e047

File tree

8 files changed

+389
-10
lines changed

8 files changed

+389
-10
lines changed

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,43 @@ from pact.matchers import get_generated_values
249249
self.assertEqual(result, get_generated_values(expected))
250250
```
251251

252+
### Match common formats
253+
Often times, you find yourself having to re-write regular expressions for common formats.
254+
255+
```python
256+
from pact import Format
257+
Format().integer # Matches if the value is an integer
258+
Format().ip_address # Matches if the value is a ip address
259+
```
260+
261+
We've created a number of them for you to save you the time:
262+
263+
| matcher | description |
264+
|-----------------|-------------------------------------------------------------------------------------------------|
265+
| `identifier` | Match an ID (e.g. 42) |
266+
| `integer` | Match all numbers that are integers (both ints and longs) |
267+
| `decimal` | Match all real numbers (floating point and decimal) |
268+
| `hexadecimal` | Match all hexadecimal encoded strings |
269+
| `date` | Match string containing basic ISO8601 dates (e.g. 2016-01-01) |
270+
| `timestamp` | Match a string containing an RFC3339 formatted timestapm (e.g. Mon, 31 Oct 2016 15:21:41 -0400) |
271+
| `time` | Match string containing times in ISO date format (e.g. T22:44:30.652Z) |
272+
| `ip_address` | Match string containing IP4 formatted address |
273+
| `ipv6_address` | Match string containing IP6 formatted address |
274+
| `uuid` | Match strings containing UUIDs |
275+
276+
These can be used to replace other matchers
277+
278+
```python
279+
from pact import Like, Format
280+
Like({
281+
'id': Format().integer, # integer
282+
'lastUpdated': Format().timestamp, # timestamp
283+
'location': { # dictionary
284+
'host': Format().ip_address # ip address
285+
}
286+
})
287+
```
288+
252289
For more information see [Matching](https://docs.pact.io/getting_started/matching)
253290

254291
## Verifying Pacts Against a Service

examples/e2e/pact_provider.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ def setup_no_user_a():
2020
def setup_user_a_nonadmin():
2121
id = '00000000-0000-4000-a000-000000000000'
2222
some_date = '2016-12-15T20:16:01'
23+
ip_address = '198.0.0.1'
2324

2425
fakedb['UserA'] = {
2526
'name': "UserA",
2627
'id': id,
2728
'created_on': some_date,
29+
'ip_address': ip_address,
2830
'admin': False
2931
}
3032

examples/e2e/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ attrs==19.3.0
22
certifi==2019.11.28
33
chardet==3.0.4
44
click==7.1.1
5+
enum34==1.1.10
56
Flask==1.1.1
67
idna==2.9
78
importlib-metadata==1.6.0
@@ -13,7 +14,7 @@ packaging==20.3
1314
pluggy==0.13.1
1415
psutil==5.7.0
1516
py==1.8.1
16-
pyparsing==2.4.6
17+
pyparsing==2.4.6f
1718
pytest==5.4.1
1819
requests==2.23.0
1920
six==1.14.0

examples/e2e/tests/test_user_consumer.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
from requests.auth import HTTPBasicAuth
88

99
import pytest
10-
from pact import Consumer, Like, Provider, Term
10+
from pact import Consumer, Like, Provider, Term, Format
1111

1212
from ..src.consumer import UserConsumer
1313

1414
log = logging.getLogger(__name__)
1515
logging.basicConfig(level=logging.INFO)
16-
16+
print(Format().__dict__)
1717

1818
PACT_UPLOAD_URL = (
1919
"http://127.0.0.1/pacts/provider/UserService/consumer"
@@ -76,14 +76,12 @@ def push_to_broker(version):
7676
def test_get_user_non_admin(pact, consumer):
7777
expected = {
7878
'name': 'UserA',
79-
'id': Term(
80-
r'^[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}\Z', # noqa: E501
81-
'00000000-0000-4000-a000-000000000000'
82-
),
79+
'id': Format().uuid,
8380
'created_on': Term(
8481
r'\d+-\d+-\d+T\d+:\d+:\d+',
8582
'2016-12-15T20:16:01'
8683
),
84+
'ip_address': Format().ip_address,
8785
'admin': False
8886
}
8987

pact/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Python methods for interactive with a Pact Mock Service."""
22
from .consumer import Consumer
3-
from .matchers import EachLike, Like, SomethingLike, Term
3+
from .matchers import EachLike, Like, SomethingLike, Term, Format
44
from .pact import Pact
55
from .provider import Provider
66
from .__version__ import __version__ # noqa: F401
77

88
__all__ = ('Consumer', 'EachLike', 'Like', 'Pact', 'Provider', 'SomethingLike',
9-
'Term')
9+
'Term', 'Format')

pact/matchers.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Classes for defining request and response data that is variable."""
22
import six
3+
import datetime
4+
5+
from enum import Enum
36

47

58
class Matcher(object):
@@ -225,3 +228,174 @@ def get_generated_values(input):
225228
return input.generate()['data']['generate']
226229
else:
227230
raise ValueError('Unknown type: %s' % type(input))
231+
232+
233+
class Format:
234+
"""
235+
Class of regular expressions for common formats.
236+
237+
Example:
238+
239+
>>> from pact import Consumer, Provider
240+
>>> from pact.matchers import Format
241+
>>> pact = Consumer('consumer').has_pact_with(Provider('provider'))
242+
>>> (pact.given('the current user is logged in as `tester`')
243+
... .upon_receiving('a request for the user profile')
244+
... .with_request('get', '/profile')
245+
... .will_respond_with(200, body={
246+
... 'id': Format().identifier,
247+
... 'lastUpdated': Format().time
248+
... }))
249+
250+
Would expect `id` to be any valid int and `lastUpdated` to be a valid time.
251+
When the consumer runs this contract, the value of that will be returned
252+
is the second value passed to Term in the given function, for the time
253+
example it would be datetime.datetime(2000, 2, 1, 12, 30, 0, 0).time()
254+
"""
255+
256+
def __init__(self):
257+
"""Create a new Formatter."""
258+
self.identifier = self.integer_or_identifier()
259+
self.integer = self.integer_or_identifier()
260+
self.decimal = self.decimal()
261+
self.ip_address = self.ip_address()
262+
self.hexadecimal = self.hexadecimal()
263+
self.ipv6_address = self.ipv6_address()
264+
self.uuid = self.uuid()
265+
self.timestamp = self.timestamp()
266+
self.date = self.date()
267+
self.time = self.time()
268+
269+
def integer_or_identifier(self):
270+
"""
271+
Match any integer.
272+
273+
:return: a Like object with an integer.
274+
:rtype: Like
275+
"""
276+
return Like(1)
277+
278+
def decimal(self):
279+
"""
280+
Match any decimal.
281+
282+
:return: a Like object with a decimal.
283+
:rtype: Like
284+
"""
285+
return Like(1.0)
286+
287+
def ip_address(self):
288+
"""
289+
Match any ip address.
290+
291+
:return: a Term object with an ip address regex.
292+
:rtype: Term
293+
"""
294+
return Term(self.Regexes.ip_address.value, '127.0.0.1')
295+
296+
def hexadecimal(self):
297+
"""
298+
Match any hexadecimal.
299+
300+
:return: a Term object with a hexdecimal regex.
301+
:rtype: Term
302+
"""
303+
return Term(self.Regexes.hexadecimal.value, '3F')
304+
305+
def ipv6_address(self):
306+
"""
307+
Match any ipv6 address.
308+
309+
:return: a Term object with an ipv6 address regex.
310+
:rtype: Term
311+
"""
312+
return Term(self.Regexes.ipv6_address.value, '::ffff:192.0.2.128')
313+
314+
def uuid(self):
315+
"""
316+
Match any uuid.
317+
318+
:return: a Term object with a uuid regex.
319+
:rtype: Term
320+
"""
321+
return Term(
322+
self.Regexes.uuid.value, 'fc763eba-0905-41c5-a27f-3934ab26786c'
323+
)
324+
325+
def timestamp(self):
326+
"""
327+
Match any timestamp.
328+
329+
:return: a Term object with a timestamp regex.
330+
:rtype: Term
331+
"""
332+
return Term(
333+
self.Regexes.timestamp.value, datetime.datetime(
334+
2000, 2, 1, 12, 30, 0, 0
335+
)
336+
)
337+
338+
def date(self):
339+
"""
340+
Match any date.
341+
342+
:return: a Term object with a date regex.
343+
:rtype: Term
344+
"""
345+
return Term(
346+
self.Regexes.date.value, datetime.datetime(
347+
2000, 2, 1, 12, 30, 0, 0
348+
).date()
349+
)
350+
351+
def time(self):
352+
"""
353+
Match any time.
354+
355+
:return: a Term object with a time regex.
356+
:rtype: Term
357+
"""
358+
return Term(
359+
self.Regexes.time_regex.value, datetime.datetime(
360+
2000, 2, 1, 12, 30, 0, 0
361+
).time()
362+
)
363+
364+
class Regexes(Enum):
365+
"""Regex Enum for common formats."""
366+
367+
ip_address = r'(\d{1,3}\.)+\d{1,3}'
368+
hexadecimal = r'[0-9a-fA-F]+'
369+
ipv6_address = r'(\A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]{1,4}){1,6}\Z)|' \
370+
r'(\A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}\Z)|(\A([0-9a-f]' \
371+
r'{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}\Z)|(\A([0-9a-f]{1,4}:)' \
372+
r'{1,4}(:[0-9a-f]{1,4}){1,3}\Z)|(\A([0-9a-f]{1,4}:){1,5}(:[0-' \
373+
r'9a-f]{1,4}){1,2}\Z)|(\A([0-9a-f]{1,4}:){1,6}(:[0-9a-f]{1,4})' \
374+
r'{1,1}\Z)|(\A(([0-9a-f]{1,4}:){1,7}|:):\Z)|(\A:(:[0-9a-f]{1,4})' \
375+
r'{1,7}\Z)|(\A((([0-9a-f]{1,4}:){6})(25[0-5]|2[0-4]\d|[0-1]' \
376+
r'?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})\Z)|(\A(([0-9a-f]' \
377+
r'{1,4}:){5}[0-9a-f]{1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25' \
378+
r'[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})\Z)|(\A([0-9a-f]{1,4}:){5}:[' \
379+
r'0-9a-f]{1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4' \
380+
r']\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]' \
381+
r'{1,4}){1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]' \
382+
r'\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}' \
383+
r'){1,3}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0' \
384+
r'-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,' \
385+
r'2}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]' \
386+
r'?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,1}:' \
387+
r'(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?' \
388+
r'\d)){3}\Z)|(\A(([0-9a-f]{1,4}:){1,5}|:):(25[0-5]|2[0-4]\d|[0' \
389+
r'-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A:(:[' \
390+
r'0-9a-f]{1,4}){1,5}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]' \
391+
r'|2[0-4]\d|[0-1]?\d?\d)){3}\Z)'
392+
uuid = r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
393+
timestamp = r'^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3(' \
394+
r'[12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-' \
395+
r'9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2' \
396+
r'[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d' \
397+
r'([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$'
398+
date = r'^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|' \
399+
r'0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|' \
400+
r'[12]\d{2}|3([0-5]\d|6[1-6])))?)'
401+
time_regex = r'^(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?$'

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def read(filename):
117117

118118
if sys.version_info.major == 2:
119119
dependencies.append('subprocess32')
120+
dependencies.append('enum34')
120121

121122
if __name__ == '__main__':
122123
setup(

0 commit comments

Comments
 (0)