google-cloud-testutils 1.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- google_cloud_testutils-1.6.1.dist-info/LICENSE +202 -0
- google_cloud_testutils-1.6.1.dist-info/METADATA +36 -0
- google_cloud_testutils-1.6.1.dist-info/RECORD +17 -0
- google_cloud_testutils-1.6.1.dist-info/WHEEL +5 -0
- google_cloud_testutils-1.6.1.dist-info/entry_points.txt +2 -0
- google_cloud_testutils-1.6.1.dist-info/top_level.txt +1 -0
- test_utils/__init__.py +16 -0
- test_utils/imports.py +38 -0
- test_utils/lower_bound_checker/__init__.py +0 -0
- test_utils/lower_bound_checker/lower_bound_checker.py +271 -0
- test_utils/orchestrate.py +446 -0
- test_utils/prefixer.py +82 -0
- test_utils/py.typed +2 -0
- test_utils/retry.py +228 -0
- test_utils/system.py +80 -0
- test_utils/version.py +15 -0
- test_utils/vpcsc_config.py +118 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
# Copyright 2018 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import itertools
|
|
16
|
+
import math
|
|
17
|
+
import queue
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import tokenize
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def orchestrate(*tests, **kwargs):
|
|
24
|
+
"""
|
|
25
|
+
Orchestrate a deterministic concurrency test.
|
|
26
|
+
|
|
27
|
+
Runs test functions in separate threads, with each thread taking turns running up
|
|
28
|
+
until predefined syncpoints in a deterministic order. All possible orderings are
|
|
29
|
+
tested.
|
|
30
|
+
|
|
31
|
+
Most of the time, we try to use logic, best practices, and static analysis to insure
|
|
32
|
+
correct operation of concurrent code. Sometimes our powers of reasoning fail us and,
|
|
33
|
+
either through non-determistic stress testing or running code in production, a
|
|
34
|
+
concurrent bug is discovered. When this occurs, we'd like to have a regression test
|
|
35
|
+
to insure we've understood the problem and implemented a correct solution.
|
|
36
|
+
`orchestrate` provides a means of deterministically testing concurrent code so we
|
|
37
|
+
can write robust regression tests for complex concurrent scenarios.
|
|
38
|
+
|
|
39
|
+
`orchestrate` runs each passed in test function in its own thread. Threads then
|
|
40
|
+
"take turns" running. Turns are defined by setting syncpoints in the code under
|
|
41
|
+
test, using comments containing "pragma: SYNCPOINT". `orchestrate` will scan the
|
|
42
|
+
code under test and add syncpoints where it finds these comments.
|
|
43
|
+
|
|
44
|
+
For example, let's say you have the following code in production::
|
|
45
|
+
|
|
46
|
+
def hither_and_yon(destination):
|
|
47
|
+
hither(destination)
|
|
48
|
+
yon(destination)
|
|
49
|
+
|
|
50
|
+
You've found there's a concurrency bug when two threads execute this code with the
|
|
51
|
+
same argument, and you think that by adding a syncpoint between the calls to
|
|
52
|
+
`hither` and `yon` you can reproduce the problem in a regression test. First add a
|
|
53
|
+
comment with "pragma: SYNCPOINT" to the code under test::
|
|
54
|
+
|
|
55
|
+
def hither_and_yon(destination):
|
|
56
|
+
hither(destination) # pragma: SYNCPOINT
|
|
57
|
+
yon(destination)
|
|
58
|
+
|
|
59
|
+
When testing with orchestrate, there will now be a syncpoint, or a pause, after the
|
|
60
|
+
call to `hither` and before the call to `yon`. Now you can write a test to exercise
|
|
61
|
+
`hither_and_yon` running in parallel::
|
|
62
|
+
|
|
63
|
+
from unittest import mock
|
|
64
|
+
from tests.unit import orchestrate
|
|
65
|
+
|
|
66
|
+
from myorg.myproj.sales import travel
|
|
67
|
+
|
|
68
|
+
def test_concurrent_hither_and_yon():
|
|
69
|
+
|
|
70
|
+
def test_hither_and_yon():
|
|
71
|
+
assert something
|
|
72
|
+
travel.hither_and_yon("Raleigh")
|
|
73
|
+
assert something_else
|
|
74
|
+
|
|
75
|
+
counts = orchestrate.orchestrate(test_hither_and_yon, test_hither_and_yon)
|
|
76
|
+
assert counts == (2, 2)
|
|
77
|
+
|
|
78
|
+
What `orchestrate` will do now is take each of the two test functions passed in
|
|
79
|
+
(actually the same function, twice, in this case), run them serially, and count the
|
|
80
|
+
number of turns it takes to run each test to completion. In this example, it will
|
|
81
|
+
take two turns for each test: one turn to start the thread and execute up until the
|
|
82
|
+
syncpoint, and then another turn to execute from the syncpoint to the end of the
|
|
83
|
+
test. The number of turns will always be one greater than the number of syncpoints
|
|
84
|
+
encountered when executing the test.
|
|
85
|
+
|
|
86
|
+
Once the counts have been taken, `orchestrate` will construct a test sequence that
|
|
87
|
+
represents all of the turns taken by the passed in tests, with each value in the
|
|
88
|
+
sequence representing the index of the test whose turn it is in the sequence. In
|
|
89
|
+
this example, then, it would produce::
|
|
90
|
+
|
|
91
|
+
[0, 0, 1, 1]
|
|
92
|
+
|
|
93
|
+
This represents the first test taking both of its turns, followed by the second test
|
|
94
|
+
taking both of its turns. At this point this scenario has already been tested,
|
|
95
|
+
because this is what was run to produce the counts and the initial test sequence.
|
|
96
|
+
Now `orchestrate` will run all of the remaining scenarios by finding all the
|
|
97
|
+
permutations of the test sequence and executing those, in turn::
|
|
98
|
+
|
|
99
|
+
[0, 1, 0, 1]
|
|
100
|
+
[0, 1, 1, 0]
|
|
101
|
+
[1, 0, 0, 1]
|
|
102
|
+
[1, 0, 1, 0]
|
|
103
|
+
[1, 1, 0, 0]
|
|
104
|
+
|
|
105
|
+
You'll notice in our example that since both test functions are actually the same
|
|
106
|
+
function, that although it tested 6 scenarios there are effectively only really 3
|
|
107
|
+
unique scenarios. For the time being, though, `orchestrate` doesn't attempt to
|
|
108
|
+
detect this condition or optimize for it.
|
|
109
|
+
|
|
110
|
+
There are some performance considerations that should be taken into account when
|
|
111
|
+
writing tests. The number of unique test sequences grows quite quickly with the
|
|
112
|
+
number of turns taken by the functions under test. Our simple example with two
|
|
113
|
+
threads each taking two turns, only yielded 6 scenarios, but two threads each taking
|
|
114
|
+
6 turns, for example, yields 924 scenarios. Add another six step thread, for a total
|
|
115
|
+
of three threads, and now you have over 17 thousand scenarios. In general, use the
|
|
116
|
+
least number of steps/threads you can get away with and still expose the behavior
|
|
117
|
+
you want to correct.
|
|
118
|
+
|
|
119
|
+
For the same reason as above, it is recommended that if you have many concurrent
|
|
120
|
+
tests, that you name your syncpoints so that you're not accidentally using
|
|
121
|
+
syncpoints intended for other tests, as this will add steps to your tests. While
|
|
122
|
+
it's not problematic from a testing standpoint to have extra steps in your tests, it
|
|
123
|
+
can use computing resources unnecessarily. A name can be added to any syncpoint
|
|
124
|
+
after the `SYNCPOINT` keyword in the pragma definition::
|
|
125
|
+
|
|
126
|
+
def hither_and_yon(destination):
|
|
127
|
+
hither(destination) # pragma: SYNCPOINT hither and yon
|
|
128
|
+
yon(destination)
|
|
129
|
+
|
|
130
|
+
In your test, then, pass that name to `orchestrate` to cause it to use only
|
|
131
|
+
syncpoints with that name::
|
|
132
|
+
|
|
133
|
+
orchestrate.orchestrate(
|
|
134
|
+
test_hither_and_yon, test_hither_and_yon, name="hither and yon"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
As soon as any error or failure is detected, no more scenarios are run
|
|
138
|
+
and that error is propagated to the main thread.
|
|
139
|
+
|
|
140
|
+
One limitation of `orchestrate` is that it cannot really be used with `coverage`,
|
|
141
|
+
since both tools use `sys.set_trace`. Any code that needs verifiable test coverage
|
|
142
|
+
should have additional tests that do not use `orchestrate`, since code that is run
|
|
143
|
+
under orchestrate will not show up in a coverage report generated by `coverage`.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
tests (Tuple[Callable]): Test functions to be run. These functions will not be
|
|
147
|
+
called with any arguments, so they must not have any required arguments.
|
|
148
|
+
name (Optional[str]): Only use syncpoints with the given name. If omitted, only
|
|
149
|
+
unnamed syncpoints will be used.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Tuple[int]: A tuple of the count of the number turns for test passed in. Can be
|
|
153
|
+
used a sanity check in tests to make sure you understand what's actually
|
|
154
|
+
happening during a test.
|
|
155
|
+
"""
|
|
156
|
+
name = kwargs.pop("name", None)
|
|
157
|
+
if kwargs:
|
|
158
|
+
raise TypeError(
|
|
159
|
+
"Unexpected keyword arguments: {}".format(", ".join(kwargs.keys()))
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Produce an initial test sequence. The fundamental question we're always trying to
|
|
163
|
+
# answer is "whose turn is it?" First we'll find out how many "turns" each test
|
|
164
|
+
# needs to complete when run serially and use that to construct a sequence of
|
|
165
|
+
# indexes. When a test's index appears in the sequence, it is that test's turn to
|
|
166
|
+
# run. We'll start by constructing a sequence that would run each test through to
|
|
167
|
+
# completion serially, one after the other.
|
|
168
|
+
test_sequence = []
|
|
169
|
+
counts = []
|
|
170
|
+
for index, test in enumerate(tests):
|
|
171
|
+
thread = _TestThread(test, name)
|
|
172
|
+
for count in itertools.count(1): # pragma: NO BRANCH
|
|
173
|
+
# Pragma is required because loop never finishes naturally.
|
|
174
|
+
thread.go()
|
|
175
|
+
if thread.finished:
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
counts.append(count)
|
|
179
|
+
test_sequence += [index] * count
|
|
180
|
+
|
|
181
|
+
# Now we can take that initial sequence and generate all of its permutations,
|
|
182
|
+
# running each one to try to uncover concurrency bugs
|
|
183
|
+
sequences = iter(_permutations(test_sequence))
|
|
184
|
+
|
|
185
|
+
# We already tested the first sequence getting our counts, so we can discard it
|
|
186
|
+
next(sequences)
|
|
187
|
+
|
|
188
|
+
# Test each sequence
|
|
189
|
+
for test_sequence in sequences:
|
|
190
|
+
threads = [_TestThread(test, name) for test in tests]
|
|
191
|
+
try:
|
|
192
|
+
for index in test_sequence:
|
|
193
|
+
threads[index].go()
|
|
194
|
+
|
|
195
|
+
# Its possible for number of turns to vary from one test run to the other,
|
|
196
|
+
# especially if there is some undiscovered concurrency bug. Go ahead and
|
|
197
|
+
# finish running each test to completion, if not already complete.
|
|
198
|
+
for thread in threads:
|
|
199
|
+
while not thread.finished:
|
|
200
|
+
thread.go()
|
|
201
|
+
|
|
202
|
+
except Exception:
|
|
203
|
+
# If an exception occurs, we still need to let any threads that are still
|
|
204
|
+
# going finish up. Additional exceptions are silently ignored.
|
|
205
|
+
for thread in threads:
|
|
206
|
+
thread.finish()
|
|
207
|
+
raise
|
|
208
|
+
|
|
209
|
+
return tuple(counts)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_local = threading.local()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class _Conductor:
|
|
216
|
+
"""Coordinate communication between main thread and a test thread.
|
|
217
|
+
|
|
218
|
+
Two way communicaton is maintained between the main thread and a test thread using
|
|
219
|
+
two synchronized queues (`queue.Queue`) each with a size of one.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(self):
|
|
223
|
+
self._notify = queue.Queue(1)
|
|
224
|
+
self._go = queue.Queue(1)
|
|
225
|
+
|
|
226
|
+
def notify(self):
|
|
227
|
+
"""Called from test thread to let us know it's finished or is ready for its next
|
|
228
|
+
turn."""
|
|
229
|
+
self._notify.put(None)
|
|
230
|
+
|
|
231
|
+
def standby(self):
|
|
232
|
+
"""Called from test thread in order to block until told to go."""
|
|
233
|
+
self._go.get()
|
|
234
|
+
|
|
235
|
+
def wait(self):
|
|
236
|
+
"""Called from main thread to wait for test thread to either get to the
|
|
237
|
+
next syncpoint or finish."""
|
|
238
|
+
self._notify.get()
|
|
239
|
+
|
|
240
|
+
def go(self):
|
|
241
|
+
"""Called from main thread to tell test thread to go."""
|
|
242
|
+
self._go.put(None)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_SYNCPOINTS = {}
|
|
246
|
+
"""Dict[str, Dict[str, Set[int]]]: Dict mapping source fileneme to a dict mapping
|
|
247
|
+
syncpoint name to set of line numbers where syncpoints with that name occur in the
|
|
248
|
+
source file.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _get_syncpoints(filename):
|
|
253
|
+
"""Find syncpoints in a source file.
|
|
254
|
+
|
|
255
|
+
Does a simple tokenization of the source file, looking for comments with "pragma:
|
|
256
|
+
SYNCPOINT", and populates _SYNCPOINTS using the syncpoint name and line number in
|
|
257
|
+
the source file.
|
|
258
|
+
"""
|
|
259
|
+
_SYNCPOINTS[filename] = syncpoints = {}
|
|
260
|
+
|
|
261
|
+
# Use tokenize to find pragma comments
|
|
262
|
+
with open(filename, "r") as pyfile:
|
|
263
|
+
tokens = tokenize.generate_tokens(pyfile.readline)
|
|
264
|
+
for type, value, start, end, line in tokens:
|
|
265
|
+
if type == tokenize.COMMENT and "pragma: SYNCPOINT" in value:
|
|
266
|
+
name = value.split("SYNCPOINT", 1)[1].strip()
|
|
267
|
+
if not name:
|
|
268
|
+
name = None
|
|
269
|
+
|
|
270
|
+
if name not in syncpoints:
|
|
271
|
+
syncpoints[name] = set()
|
|
272
|
+
|
|
273
|
+
lineno, column = start
|
|
274
|
+
syncpoints[name].add(lineno)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class _TestThread:
|
|
278
|
+
"""A thread for a test function."""
|
|
279
|
+
|
|
280
|
+
thread = None
|
|
281
|
+
finished = False
|
|
282
|
+
error = None
|
|
283
|
+
at_syncpoint = False
|
|
284
|
+
|
|
285
|
+
def __init__(self, test, name):
|
|
286
|
+
self.test = test
|
|
287
|
+
self.name = name
|
|
288
|
+
self.conductor = _Conductor()
|
|
289
|
+
|
|
290
|
+
def _run(self):
|
|
291
|
+
sys.settrace(self._trace)
|
|
292
|
+
_local.conductor = self.conductor
|
|
293
|
+
try:
|
|
294
|
+
self.test()
|
|
295
|
+
except Exception as error:
|
|
296
|
+
self.error = error
|
|
297
|
+
finally:
|
|
298
|
+
self.finished = True
|
|
299
|
+
self.conductor.notify()
|
|
300
|
+
|
|
301
|
+
def _sync(self):
|
|
302
|
+
# Tell main thread we're finished, for now
|
|
303
|
+
self.conductor.notify()
|
|
304
|
+
|
|
305
|
+
# Wait for the main thread to tell us to go again
|
|
306
|
+
self.conductor.standby()
|
|
307
|
+
|
|
308
|
+
def _trace(self, frame, event, arg):
|
|
309
|
+
"""Argument to `sys.settrace`.
|
|
310
|
+
|
|
311
|
+
Handles frames during test run, syncing at syncpoints, when found.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
`None` if no more tracing is required for the function call, `self._trace`
|
|
315
|
+
if tracing should continue.
|
|
316
|
+
"""
|
|
317
|
+
if self.at_syncpoint:
|
|
318
|
+
# We hit a syncpoint on the previous call, so now we sync.
|
|
319
|
+
self._sync()
|
|
320
|
+
self.at_syncpoint = False
|
|
321
|
+
|
|
322
|
+
filename = frame.f_globals.get("__file__")
|
|
323
|
+
if not filename:
|
|
324
|
+
# Can't trace code without a source file
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
if filename.endswith(".pyc"):
|
|
328
|
+
filename = filename[:-1]
|
|
329
|
+
|
|
330
|
+
if filename not in _SYNCPOINTS:
|
|
331
|
+
_get_syncpoints(filename)
|
|
332
|
+
|
|
333
|
+
syncpoints = _SYNCPOINTS[filename].get(self.name)
|
|
334
|
+
if not syncpoints:
|
|
335
|
+
# This file doesn't contain syncpoints, don't continue to trace
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
# We've hit a syncpoint. Execute whatever line the syncpoint is on and then
|
|
339
|
+
# sync next time this gets called.
|
|
340
|
+
if frame.f_lineno in syncpoints:
|
|
341
|
+
self.at_syncpoint = True
|
|
342
|
+
|
|
343
|
+
return self._trace
|
|
344
|
+
|
|
345
|
+
def go(self):
|
|
346
|
+
if self.finished:
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
if self.thread is None:
|
|
350
|
+
self.thread = threading.Thread(target=self._run)
|
|
351
|
+
self.thread.start()
|
|
352
|
+
|
|
353
|
+
else:
|
|
354
|
+
self.conductor.go()
|
|
355
|
+
|
|
356
|
+
self.conductor.wait()
|
|
357
|
+
|
|
358
|
+
if self.error:
|
|
359
|
+
raise self.error
|
|
360
|
+
|
|
361
|
+
def finish(self):
|
|
362
|
+
while not self.finished:
|
|
363
|
+
try:
|
|
364
|
+
self.go()
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class _permutations:
|
|
370
|
+
"""Generates a sequence of all permutations of `sequence`.
|
|
371
|
+
|
|
372
|
+
Permutations are returned in lexicographic order using the "Generation in
|
|
373
|
+
lexicographic order" algorithm described in `the Wikipedia article on "Permutation"
|
|
374
|
+
<https://en.wikipedia.org/wiki/Permutation>`_.
|
|
375
|
+
|
|
376
|
+
This implementation differs significantly from `itertools.permutations` in that the
|
|
377
|
+
value of individual elements is taken into account, thus eliminating redundant
|
|
378
|
+
orderings that would be produced by `itertools.permutations`.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
sequence (Sequence[Any]): Sequence must be finite and orderable.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Sequence[Sequence[Any]]: Set of all permutations of `sequence`.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
def __init__(self, sequence):
|
|
388
|
+
self._start = tuple(sorted(sequence))
|
|
389
|
+
|
|
390
|
+
def __len__(self):
|
|
391
|
+
"""Compute the number of permutations.
|
|
392
|
+
|
|
393
|
+
Let the number of elements in a sequence N and the number of repetitions for
|
|
394
|
+
individual members of the sequence be n1, n2, ... nx. The number of unique
|
|
395
|
+
permutations is: N! / n1! / n2! / ... / nx!.
|
|
396
|
+
|
|
397
|
+
For example, let `sequence` be [1, 2, 3, 1, 2, 3, 1, 2, 3]. The number of unique
|
|
398
|
+
permutations is: 9! / 3! / 3! / 3! = 1680.
|
|
399
|
+
|
|
400
|
+
See: "Permutations of multisets" in `the Wikipedia article on "Permutation"
|
|
401
|
+
<https://en.wikipedia.org/wiki/Permutation>`_.
|
|
402
|
+
"""
|
|
403
|
+
repeats = [len(list(group)) for value, group in itertools.groupby(self._start)]
|
|
404
|
+
length = math.factorial(len(self._start))
|
|
405
|
+
for repeat in repeats:
|
|
406
|
+
length /= math.factorial(repeat)
|
|
407
|
+
|
|
408
|
+
return int(length)
|
|
409
|
+
|
|
410
|
+
def __iter__(self):
|
|
411
|
+
"""Iterate over permutations.
|
|
412
|
+
|
|
413
|
+
See: "Generation in lexicographic order" algorithm described in `the Wikipedia
|
|
414
|
+
article on "Permutation" <https://en.wikipedia.org/wiki/Permutation>`_.
|
|
415
|
+
"""
|
|
416
|
+
current = list(self._start)
|
|
417
|
+
size = len(current)
|
|
418
|
+
|
|
419
|
+
while True:
|
|
420
|
+
yield tuple(current)
|
|
421
|
+
|
|
422
|
+
# 1. Find the largest index i such that a[i] < a[i + 1].
|
|
423
|
+
for i in range(size - 2, -1, -1):
|
|
424
|
+
if current[i] < current[i + 1]:
|
|
425
|
+
break
|
|
426
|
+
|
|
427
|
+
else:
|
|
428
|
+
# If no such index exists, the permutation is the last permutation.
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
# 2. Find the largest index j greater than i such that a[i] < a[j].
|
|
432
|
+
for j in range(size - 1, i, -1):
|
|
433
|
+
if current[i] < current[j]:
|
|
434
|
+
break
|
|
435
|
+
|
|
436
|
+
else: # pragma: NO COVER
|
|
437
|
+
raise RuntimeError("Broken algorithm")
|
|
438
|
+
|
|
439
|
+
# 3. Swap the value of a[i] with that of a[j].
|
|
440
|
+
temp = current[i]
|
|
441
|
+
current[i] = current[j]
|
|
442
|
+
current[j] = temp
|
|
443
|
+
|
|
444
|
+
# 4. Reverse the sequence from a[i + 1] up to and including the final
|
|
445
|
+
# element a[n].
|
|
446
|
+
current = current[: i + 1] + list(reversed(current[i + 1 :]))
|
test_utils/prefixer.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Copyright 2021 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import random
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from typing import Union
|
|
20
|
+
|
|
21
|
+
_RESOURCE_DATE_FORMAT = "%Y%m%d%H%M%S"
|
|
22
|
+
_RESOURCE_DATE_LENGTH = 4 + 2 + 2 + 2 + 2 + 2
|
|
23
|
+
_RE_SEPARATORS = re.compile(r"[/\-\\_]")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _common_prefix(repo, relative_dir, separator="_"):
|
|
27
|
+
repo = _RE_SEPARATORS.sub(separator, repo)
|
|
28
|
+
relative_dir = _RE_SEPARATORS.sub(separator, relative_dir)
|
|
29
|
+
return f"{repo}{separator}{relative_dir}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Prefixer(object):
|
|
33
|
+
"""Create/manage resource IDs for system testing.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
|
|
37
|
+
Creating resources:
|
|
38
|
+
|
|
39
|
+
>>> import test_utils.prefixer
|
|
40
|
+
>>> prefixer = test_utils.prefixer.Prefixer("python-bigquery", "samples/snippets")
|
|
41
|
+
>>> dataset_id = prefixer.create_prefix() + "my_sample"
|
|
42
|
+
|
|
43
|
+
Cleaning up resources:
|
|
44
|
+
|
|
45
|
+
>>> @pytest.fixture(scope="session", autouse=True)
|
|
46
|
+
... def cleanup_datasets(bigquery_client: bigquery.Client):
|
|
47
|
+
... for dataset in bigquery_client.list_datasets():
|
|
48
|
+
... if prefixer.should_cleanup(dataset.dataset_id):
|
|
49
|
+
... bigquery_client.delete_dataset(
|
|
50
|
+
... dataset, delete_contents=True, not_found_ok=True
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self, repo, relative_dir, separator="_", cleanup_age=datetime.timedelta(days=1)
|
|
55
|
+
):
|
|
56
|
+
self._separator = separator
|
|
57
|
+
self._cleanup_age = cleanup_age
|
|
58
|
+
self._prefix = _common_prefix(repo, relative_dir, separator=separator)
|
|
59
|
+
|
|
60
|
+
def create_prefix(self) -> str:
|
|
61
|
+
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
|
62
|
+
timestamp = now.strftime(_RESOURCE_DATE_FORMAT)
|
|
63
|
+
random_string = hex(random.randrange(0x1000000))[2:]
|
|
64
|
+
return f"{self._prefix}{self._separator}{timestamp}{self._separator}{random_string}"
|
|
65
|
+
|
|
66
|
+
def _name_to_date(self, resource_name: str) -> Union[datetime.datetime, None]:
|
|
67
|
+
start_date = len(self._prefix) + len(self._separator)
|
|
68
|
+
date_string = resource_name[start_date : start_date + _RESOURCE_DATE_LENGTH]
|
|
69
|
+
try:
|
|
70
|
+
parsed_date = datetime.datetime.strptime(date_string, _RESOURCE_DATE_FORMAT)
|
|
71
|
+
return parsed_date
|
|
72
|
+
except ValueError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def should_cleanup(self, resource_name: str) -> bool:
|
|
76
|
+
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
|
77
|
+
yesterday = now - self._cleanup_age
|
|
78
|
+
if not resource_name.startswith(self._prefix):
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
created_date = self._name_to_date(resource_name)
|
|
82
|
+
return created_date is not None and created_date < yesterday
|
test_utils/py.typed
ADDED