-
Notifications
You must be signed in to change notification settings - Fork 141
/
Copy pathmatchers.py
489 lines (413 loc) · 16.8 KB
/
matchers.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
"""Classes for defining request and response data that is variable."""
import warnings
import six
import datetime
from enum import Enum
class Matcher(object):
"""Base class for defining complex contract expectations."""
def generate(self):
"""
Get the value that the mock service should use for this Matcher.
:rtype: any
"""
raise NotImplementedError
class EachLike(Matcher):
"""
Expect the data to be a list of similar objects.
Example:
>>> from pact import Consumer, Provider
>>> pact = Consumer('consumer').has_pact_with(Provider('provider'))
>>> (pact.given('there are three comments')
... .upon_receiving('a request for the most recent 2 comments')
... .with_request('get', '/comment', query={'limit': 2})
... .will_respond_with(200, body={
... 'comments': EachLike(
... {'name': SomethingLike('bob'),
... 'text': SomethingLike('Hello!')},
... minimum=2)
... }))
Would expect the response to be a JSON object, with a comments list. In
that list should be at least 2 items, and each item should be a `dict`
with the keys `name` and `text`,
"""
def __init__(self, matcher, minimum=1):
"""
Create a new EachLike.
:param matcher: The expected value that each item in a list should
look like, this can be other matchers.
:type matcher: None, list, dict, int, float, str, unicode, Matcher
:param minimum: The minimum number of items expected.
Must be greater than or equal to 1.
:type minimum: int
"""
warnings.warn(
"This class will be deprecated Pact Python v3 "
"(see pact-foundation/pact-python#396)",
PendingDeprecationWarning,
stacklevel=2,
)
self.matcher = matcher
assert minimum >= 1, 'Minimum must be greater than or equal to 1'
self.minimum = minimum
def generate(self):
"""
Generate the value the mock service will return.
:return: A dict containing the information about the contents of the
list and the provided minimum number of items for that list.
:rtype: dict
"""
return {
'json_class': 'Pact::ArrayLike',
'contents': from_term(self.matcher),
'min': self.minimum}
class Like(Matcher):
"""
Expect the type of the value to be the same as matcher.
Example:
>>> from pact import Consumer, Provider
>>> pact = Consumer('consumer').has_pact_with(Provider('provider'))
>>> (pact
... .given('there is a random number generator')
... .upon_receiving('a request for a random number')
... .with_request('get', '/generate-number')
... .will_respond_with(200, body={
... 'number': Like(1111222233334444)
... }))
Would expect the response body to be a JSON object, containing the key
`number`, which would contain an integer. When the consumer runs this
contract, the value `1111222233334444` will be returned by the mock
service, instead of a randomly generated value.
"""
def __init__(self, matcher):
"""
Create a new SomethingLike.
:param matcher: The object that should be expected. The mock service
will return this value. When verified against the provider, the
type of this value will be asserted, while the value will be
ignored.
:type matcher: None, list, dict, int, float, str, unicode, Matcher
"""
warnings.warn(
"This class will be deprecated Pact Python v3 "
"(see pact-foundation/pact-python#396)",
PendingDeprecationWarning,
stacklevel=2,
)
valid_types = (
type(None), list, dict, int, float, six.string_types, Matcher)
assert isinstance(matcher, valid_types), (
"matcher must be one of '{}', got '{}'".format(
valid_types, type(matcher)))
self.matcher = matcher
def generate(self):
"""
Return the value that should be used in the request/response.
:return: A dict containing the information about what the contents of
the response should be.
:rtype: dict
"""
return {
'json_class': 'Pact::SomethingLike',
'contents': from_term(self.matcher)}
# Remove SomethingLike in major version 1.0.0
SomethingLike = Like
class Term(Matcher):
"""
Expect the response to match a specified regular expression.
Example:
>>> from pact import Consumer, Provider
>>> pact = Consumer('consumer').has_pact_with(Provider('provider'))
>>> (pact.given('the current user is logged in as `tester`')
... .upon_receiving('a request for the user profile')
... .with_request('get', '/profile')
... .will_respond_with(200, body={
... 'name': 'tester',
... 'theme': Term('light|dark|legacy', 'dark')
... }))
Would expect the response body to be a JSON object, containing the key
`name`, which will contain the value `tester`, and `theme` which must be
one of the values: light, dark, or legacy. When the consumer runs this
contract, the value `dark` will be returned by the mock service.
"""
def __init__(self, matcher, generate):
"""
Create a new Term.
:param matcher: A regular expression to find.
:type matcher: basestring
:param generate: A value to be returned by the mock service when
generating the response to the consumer.
:type generate: basestring
"""
warnings.warn(
"This class will be deprecated Pact Python v3 "
"(see pact-foundation/pact-python#396)",
PendingDeprecationWarning,
stacklevel=2,
)
self.matcher = matcher
self._generate = generate
def generate(self):
"""
Return the value that should be used in the request/response.
:return: A dict containing the information about what the contents of
the response should be, and what should match for the requests.
:rtype: dict
"""
return {
'json_class': 'Pact::Term',
'data': {
'generate': self._generate,
'matcher': {
'json_class': 'Regexp',
'o': 0,
's': self.matcher}}}
def from_term(term):
"""
Parse the provided term into the JSON for the mock service.
:param term: The term to be parsed.
:type term: None, list, dict, int, float, str, bytes, unicode, Matcher
:return: The JSON representation for this term.
:rtype: dict, list, str
"""
warnings.warn(
"This function will be deprecated Pact Python v3 "
"(see pact-foundation/pact-python#396)",
PendingDeprecationWarning,
stacklevel=2,
)
if term is None:
return term
elif isinstance(term, (six.string_types, six.binary_type, int, float)):
return term
elif isinstance(term, dict):
return {k: from_term(v) for k, v in term.items()}
elif isinstance(term, list):
return [from_term(t) for i, t in enumerate(term)]
elif issubclass(term.__class__, (Matcher,)):
return term.generate()
else:
raise ValueError('Unknown type: %s' % type(term))
def get_generated_values(input):
"""
Resolve (nested) Matchers to their generated values for assertion.
:param input: The input to be resolved to its generated values.
:type input: None, list, dict, int, float, bool, str, unicode, Matcher
:return: The input resolved to its generated value(s)
:rtype: None, list, dict, int, float, bool, str, unicode, Matcher
"""
warnings.warn(
"This function will be deprecated Pact Python v3 "
"(see pact-foundation/pact-python#396)",
PendingDeprecationWarning,
stacklevel=2,
)
if input is None:
return input
if isinstance(input, (six.string_types, int, float, bool)):
return input
if isinstance(input, dict):
return {k: get_generated_values(v) for k, v in input.items()}
if isinstance(input, list):
return [get_generated_values(t) for i, t in enumerate(input)]
elif isinstance(input, Like):
return get_generated_values(input.matcher)
elif isinstance(input, EachLike):
return [get_generated_values(input.matcher)] * input.minimum
elif isinstance(input, Term):
return input.generate()['data']['generate']
else:
raise ValueError('Unknown type: %s' % type(input))
class Format:
"""
Class of regular expressions for common formats.
Example:
>>> from pact import Consumer, Provider
>>> from pact.matchers import Format
>>> pact = Consumer('consumer').has_pact_with(Provider('provider'))
>>> (pact.given('the current user is logged in as `tester`')
... .upon_receiving('a request for the user profile')
... .with_request('get', '/profile')
... .will_respond_with(200, body={
... 'id': Format().identifier,
... 'lastUpdated': Format().time
... }))
Would expect `id` to be any valid int and `lastUpdated` to be a valid time.
When the consumer runs this contract, the value of that will be returned
is the second value passed to Term in the given function, for the time
example it would be datetime.datetime(2000, 2, 1, 12, 30, 0, 0).time()
"""
def __init__(self):
"""Create a new Formatter."""
warnings.warn(
"This class will be deprecated Pact Python v3 "
"(see pact-foundation/pact-python#396)",
PendingDeprecationWarning,
stacklevel=2,
)
self.identifier = self.integer_or_identifier()
self.integer = self.integer_or_identifier()
self.decimal = self.decimal()
self.ip_address = self.ip_address()
self.hexadecimal = self.hexadecimal()
self.ipv6_address = self.ipv6_address()
self.uuid = self.uuid()
self.timestamp = self.timestamp()
self.date = self.date()
self.time = self.time()
self.iso_datetime = self.iso_8601_datetime()
self.iso_datetime_ms = self.iso_8601_datetime(with_ms=True)
def integer_or_identifier(self):
"""
Match any integer.
:return: a Like object with an integer.
:rtype: Like
"""
return Like(1)
def decimal(self):
"""
Match any decimal.
:return: a Like object with a decimal.
:rtype: Like
"""
return Like(1.0)
def ip_address(self):
"""
Match any ip address.
:return: a Term object with an ip address regex.
:rtype: Term
"""
return Term(self.Regexes.ip_address.value, '127.0.0.1')
def hexadecimal(self):
"""
Match any hexadecimal.
:return: a Term object with a hexdecimal regex.
:rtype: Term
"""
return Term(self.Regexes.hexadecimal.value, '3F')
def ipv6_address(self):
"""
Match any ipv6 address.
:return: a Term object with an ipv6 address regex.
:rtype: Term
"""
return Term(self.Regexes.ipv6_address.value, '::ffff:192.0.2.128')
def uuid(self):
"""
Match any uuid.
:return: a Term object with a uuid regex.
:rtype: Term
"""
return Term(
self.Regexes.uuid.value, 'fc763eba-0905-41c5-a27f-3934ab26786c'
)
def timestamp(self):
"""
Match any timestamp.
:return: a Term object with a timestamp regex.
:rtype: Term
"""
return Term(
self.Regexes.timestamp.value, datetime.datetime(
2000, 2, 1, 12, 30, 0, 0
).isoformat()
)
def date(self):
"""
Match any date.
:return: a Term object with a date regex.
:rtype: Term
"""
return Term(
self.Regexes.date.value, datetime.datetime(
2000, 2, 1, 12, 30, 0, 0
).date().isoformat()
)
def time(self):
"""
Match any time.
:return: a Term object with a time regex.
:rtype: Term
"""
return Term(
self.Regexes.time_regex.value, datetime.datetime(
2000, 2, 1, 12, 30, 0, 0
).time().isoformat()
)
def iso_8601_datetime(self, with_ms=False):
"""
Match a string for a full ISO 8601 Date.
Does not do any sort of date validation, only checks if the string is
according to the ISO 8601 spec.
This method differs from :func:`~pact.Format.timestamp`,
:func:`~pact.Format.date` and :func:`~pact.Format.time` implementations
in that it is more stringent and tests the string for exact match to
the ISO 8601 dates format.
Without `with_ms` will match string containing ISO 8601 formatted dates
as stated bellow:
* 2016-12-15T20:16:01
* 2010-05-01T01:14:31.876
* 2016-05-24T15:54:14.00000Z
* 1994-11-05T08:15:30-05:00
* 2002-01-31T23:00:00.1234-02:00
* 1991-02-20T06:35:26.079043+00:00
Otherwise, ONLY dates with milliseconds will match the pattern:
* 2010-05-01T01:14:31.876
* 2016-05-24T15:54:14.00000Z
* 2002-01-31T23:00:00.1234-02:00
* 1991-02-20T06:35:26.079043+00:00
:param with_ms: Enforcing millisecond precision.
:type with_ms: bool
:return: a Term object with a date regex.
:rtype: Term
"""
date = [1991, 2, 20, 6, 35, 26]
if with_ms:
matcher = self.Regexes.iso_8601_datetime_ms.value
date.append(79043)
else:
matcher = self.Regexes.iso_8601_datetime.value
return Term(
matcher,
datetime.datetime(*date, tzinfo=datetime.timezone.utc).isoformat()
)
class Regexes(Enum):
"""Regex Enum for common formats."""
ip_address = r'(\d{1,3}\.)+\d{1,3}'
hexadecimal = r'[0-9a-fA-F]+'
ipv6_address = r'(\A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]{1,4}){1,6}\Z)|' \
r'(\A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}\Z)|(\A([0-9a-f]' \
r'{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}\Z)|(\A([0-9a-f]{1,4}:)' \
r'{1,4}(:[0-9a-f]{1,4}){1,3}\Z)|(\A([0-9a-f]{1,4}:){1,5}(:[0-' \
r'9a-f]{1,4}){1,2}\Z)|(\A([0-9a-f]{1,4}:){1,6}(:[0-9a-f]{1,4})' \
r'{1,1}\Z)|(\A(([0-9a-f]{1,4}:){1,7}|:):\Z)|(\A:(:[0-9a-f]{1,4})' \
r'{1,7}\Z)|(\A((([0-9a-f]{1,4}:){6})(25[0-5]|2[0-4]\d|[0-1]' \
r'?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})\Z)|(\A(([0-9a-f]' \
r'{1,4}:){5}[0-9a-f]{1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25' \
r'[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})\Z)|(\A([0-9a-f]{1,4}:){5}:[' \
r'0-9a-f]{1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4' \
r']\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]' \
r'{1,4}){1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]' \
r'\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}' \
r'){1,3}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0' \
r'-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,' \
r'2}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]' \
r'?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,1}:' \
r'(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?' \
r'\d)){3}\Z)|(\A(([0-9a-f]{1,4}:){1,5}|:):(25[0-5]|2[0-4]\d|[0' \
r'-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A:(:[' \
r'0-9a-f]{1,4}){1,5}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]' \
r'|2[0-4]\d|[0-1]?\d?\d)){3}\Z)'
uuid = r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
timestamp = r'^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3(' \
r'[12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-' \
r'9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2' \
r'[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d' \
r'([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$'
date = r'^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|' \
r'0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|' \
r'[12]\d{2}|3([0-5]\d|6[1-6])))?)'
time_regex = r'^(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?$'
iso_8601_datetime = r'^\d{4}-[01]\d-[0-3]\d\x54[0-2]\d:[0-6]\d:' \
r'[0-6]\d(?:\.\d+)?(?:(?:[+-]\d\d:\d\d)|\x5A)?$'
iso_8601_datetime_ms = r'^\d{4}-[01]\d-[0-3]\d\x54[0-2]\d:[0-6]\d:' \
r'[0-6]\d\.\d+(?:(?:[+-]\d\d:\d\d)|\x5A)?$'