forked from HypothesisWorks/hypothesis
-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathhypothesistooling.py
402 lines (304 loc) · 11.1 KB
/
hypothesistooling.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
# coding=utf-8
#
# This file is part of Hypothesis, which may be found at
# https://github.com/HypothesisWorks/hypothesis-python
#
# Most of this work is copyright (C) 2013-2018 David R. MacIver
# (david@drmaciver.com), but it contains contributions by others. See
# CONTRIBUTING.rst for a full list of people who may hold copyright, and
# consult the git log if you need to determine who owns an individual
# contribution.
#
# This Source Code Form is subject to the terms of the Mozilla Public License,
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at http://mozilla.org/MPL/2.0/.
#
# END HEADER
from __future__ import division, print_function, absolute_import
import os
import re
import sys
import subprocess
from datetime import datetime, timedelta
def current_branch():
return subprocess.check_output([
'git', 'rev-parse', '--abbrev-ref', 'HEAD'
]).decode('ascii').strip()
def tags():
result = [t.decode('ascii') for t in subprocess.check_output([
'git', 'tag'
]).split(b'\n')]
assert len(set(result)) == len(result)
return set(result)
ROOT = subprocess.check_output([
'git', 'rev-parse', '--show-toplevel']).decode('ascii').strip()
SRC = os.path.join(ROOT, 'src')
assert os.path.exists(SRC)
__version__ = None
__version_info__ = None
VERSION_FILE = os.path.join(ROOT, 'src/hypothesis/version.py')
with open(VERSION_FILE) as o:
exec(o.read())
assert __version__ is not None
assert __version_info__ is not None
def latest_version():
versions = []
for t in tags():
# All versions get tags but not all tags are versions (and there are
# a large number of historic tags with a different format for versions)
# so we parse each tag as a triple of ints (MAJOR, MINOR, PATCH)
# and skip any tag that doesn't match that.
assert t == t.strip()
parts = t.split('.')
if len(parts) != 3:
continue
try:
v = tuple(map(int, parts))
except ValueError:
continue
versions.append((v, t))
_, latest = max(versions)
assert latest in tags()
return latest
def hash_for_name(name):
return subprocess.check_output([
'git', 'rev-parse', name
]).decode('ascii').strip()
def is_ancestor(a, b):
check = subprocess.call([
'git', 'merge-base', '--is-ancestor', a, b
])
assert 0 <= check <= 1
return check == 0
CHANGELOG_FILE = os.path.join(ROOT, 'docs', 'changes.rst')
def changelog():
with open(CHANGELOG_FILE) as i:
return i.read()
def merge_base(a, b):
return subprocess.check_output([
'git', 'merge-base', a, b,
]).strip()
def has_source_changes(version=None):
if version is None:
version = latest_version()
# Check where we branched off from the version. We're only interested
# in whether *we* introduced any source changes, so we check diff from
# there rather than the diff to the other side.
point_of_divergence = merge_base('HEAD', version)
return subprocess.call([
'git', 'diff', '--exit-code', point_of_divergence, 'HEAD', '--', SRC,
]) != 0
def has_uncommitted_changes(filename):
return subprocess.call([
'git', 'diff', '--exit-code', filename
]) != 0
def git(*args):
subprocess.check_call(('git',) + args)
def create_tag_and_push():
assert __version__ not in tags()
git('config', 'user.name', 'Travis CI on behalf of David R. MacIver')
git('config', 'user.email', 'david@drmaciver.com')
git('config', 'core.sshCommand', 'ssh -i deploy_key')
git(
'remote', 'add', 'ssh-origin',
'git@github.com:HypothesisWorks/hypothesis-python.git'
)
git('tag', __version__)
subprocess.check_call([
'ssh-agent', 'sh', '-c',
'chmod 0600 deploy_key && ' +
'ssh-add deploy_key && ' +
'git push ssh-origin HEAD:master &&'
'git push ssh-origin --tags'
])
def build_jobs():
"""Query the Travis API to find out what the state of the other build jobs
is.
Note: This usage of Travis has been somewhat reverse engineered due
to a certain dearth of documentation as to what values what takes
when.
"""
import requests
build_id = os.environ['TRAVIS_BUILD_ID']
url = 'https://api.travis-ci.org/builds/%s' % (build_id,)
data = requests.get(url, headers={
'Accept': 'application/vnd.travis-ci.2+json'
}).json()
matrix = data['jobs']
jobs = {}
for m in matrix:
name = m['config']['env'].replace('TASK=', '')
status = m['state']
jobs.setdefault(status, []).append(name)
return jobs
def modified_files():
files = set()
for command in [
['git', 'diff', '--name-only', '--diff-filter=d',
latest_version(), 'HEAD'],
['git', 'diff', '--name-only']
]:
diff_output = subprocess.check_output(command).decode('ascii')
for l in diff_output.split('\n'):
filepath = l.strip()
if filepath:
assert os.path.exists(filepath), filepath
files.add(filepath)
return files
def all_files():
return subprocess.check_output(['git', 'ls-files']).decode(
'ascii').splitlines()
RELEASE_FILE = os.path.join(ROOT, 'RELEASE.rst')
def has_release():
return os.path.exists(RELEASE_FILE)
CHANGELOG_BORDER = re.compile(r"^-+$")
CHANGELOG_HEADER = re.compile(r"^\d+\.\d+\.\d+ - \d\d\d\d-\d\d-\d\d$")
RELEASE_TYPE = re.compile(r"^RELEASE_TYPE: +(major|minor|patch)")
MAJOR = 'major'
MINOR = 'minor'
PATCH = 'patch'
VALID_RELEASE_TYPES = (MAJOR, MINOR, PATCH)
def parse_release_file():
with open(RELEASE_FILE) as i:
release_contents = i.read()
release_lines = release_contents.split('\n')
m = RELEASE_TYPE.match(release_lines[0])
if m is not None:
release_type = m.group(1)
if release_type not in VALID_RELEASE_TYPES:
print('Unrecognised release type %r' % (release_type,))
sys.exit(1)
del release_lines[0]
release_contents = '\n'.join(release_lines).strip()
else:
print(
'RELEASE.rst does not start by specifying release type. The first '
'line of the file should be RELEASE_TYPE: followed by one of '
'major, minor, or patch, to specify the type of release that '
'this is (i.e. which version number to increment). Instead the '
'first line was %r' % (release_lines[0],)
)
sys.exit(1)
return release_type, release_contents
def update_changelog_and_version():
global __version_info__
global __version__
with open(CHANGELOG_FILE) as i:
contents = i.read()
assert '\r' not in contents
lines = contents.split('\n')
assert contents == '\n'.join(lines)
for i, l in enumerate(lines):
if CHANGELOG_BORDER.match(l):
assert CHANGELOG_HEADER.match(lines[i + 1]), repr(lines[i + 1])
assert CHANGELOG_BORDER.match(lines[i + 2]), repr(lines[i + 2])
beginning = '\n'.join(lines[:i])
rest = '\n'.join(lines[i:])
assert '\n'.join((beginning, rest)) == contents
break
release_type, release_contents = parse_release_file()
new_version = list(__version_info__)
bump = VALID_RELEASE_TYPES.index(release_type)
new_version[bump] += 1
for i in range(bump + 1, len(new_version)):
new_version[i] = 0
new_version = tuple(new_version)
new_version_string = '.'.join(map(str, new_version))
__version_info__ = new_version
__version__ = new_version_string
with open(VERSION_FILE) as i:
version_lines = i.read().split('\n')
for i, l in enumerate(version_lines):
if 'version_info' in l:
version_lines[i] = '__version_info__ = %r' % (new_version,)
break
with open(VERSION_FILE, 'w') as o:
o.write('\n'.join(version_lines))
now = datetime.utcnow()
date = max([
d.strftime('%Y-%m-%d') for d in (now, now + timedelta(hours=1))
])
heading_for_new_version = ' - '.join((new_version_string, date))
border_for_new_version = '-' * len(heading_for_new_version)
new_changelog_parts = [
beginning.strip(),
'',
border_for_new_version,
heading_for_new_version,
border_for_new_version,
'',
release_contents,
'',
rest
]
with open(CHANGELOG_FILE, 'w') as o:
o.write('\n'.join(new_changelog_parts))
def update_for_pending_release():
update_changelog_and_version()
git('rm', RELEASE_FILE)
git('add', CHANGELOG_FILE, VERSION_FILE)
git(
'commit', '-m',
'Bump version to %s and update changelog\n\n[skip ci]' % (__version__,)
)
def could_affect_tests(path):
"""Does this file have any effect on test results?"""
# RST files are the input to some tests -- in particular, the
# documentation build and doctests. Both of those jobs are always run,
# so we can ignore their effect here.
#
# IPython notebooks aren't currently used in any tests.
if path.endswith(('.rst', '.ipynb')):
return False
# These files exist but have no effect on tests.
if path in ('CITATION', 'LICENSE.txt', ):
return False
# We default to marking a file "interesting" unless we know otherwise --
# it's better to run tests that could have been skipped than skip tests
# when they needed to be run.
return True
def changed_files_from_master():
"""Returns a list of files which have changed between a branch and
master."""
files = set()
command = ['git', 'diff', '--name-only', 'HEAD', 'master']
diff_output = subprocess.check_output(command).decode('ascii')
for line in diff_output.splitlines():
filepath = line.strip()
if filepath:
files.add(filepath)
return files
def should_run_ci_task(task, is_pull_request):
"""Given a task name, should we run this task?"""
if not is_pull_request:
print('We only skip tests if the job is a pull request.')
return True
# These tests are usually fast; we always run them rather than trying
# to keep up-to-date rules of exactly which changed files mean they
# should run.
if task in [
'check-pyup-yml',
'check-release-file',
'check-shellcheck',
'documentation',
'lint',
]:
print('We always run the %s task.' % task)
return True
# The remaining tasks are all some sort of test of Hypothesis
# functionality. Since it's better to run tests when we don't need to
# than skip tests when it was important, we remove any files which we
# know are safe to ignore, and run tests if there's anything left.
changed_files = changed_files_from_master()
interesting_changed_files = [
f for f in changed_files if could_affect_tests(f)
]
if interesting_changed_files:
print(
'Changes to the following files mean we need to run tests: %s' %
', '.join(interesting_changed_files)
)
return True
else:
print('There are no changes which would need a test run.')
return False