-
-
Notifications
You must be signed in to change notification settings - Fork 187
/
Copy pathapi.py
443 lines (377 loc) · 16 KB
/
api.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
import codecs
import json
import logging
import os
import re
import coverage
import requests
from .exception import CoverallsException
from .git import git_info
from .reporter import CoverallReporter
log = logging.getLogger('coveralls.api')
NUMBER_REGEX = re.compile(r'(\d+)$', re.IGNORECASE)
class Coveralls:
# pylint: disable=too-many-public-methods
config_filename = '.coveralls.yml'
def __init__(self, token_required=True, service_name=None, **kwargs):
"""
Initialize the main Coveralls collection entrypoint.
* repo_token
The secret token for your repository, found at the bottom of your
repository's page on Coveralls.
* service_name
The CI service or other environment in which the test suite was run.
This can be anything, but certain services have special features
(travis-ci, travis-pro, or coveralls-ruby).
* [service_job_id]
A unique identifier of the job on the service specified by
service_name.
"""
self._data = None
self._coveralls_host = 'https://coveralls.io/'
self._token_required = token_required
self.config = {}
self.load_config(kwargs, service_name)
self.ensure_token()
def ensure_token(self):
if self.config.get('repo_token') or not self._token_required:
return
if os.environ.get('GITHUB_ACTIONS'):
raise CoverallsException(
'Running on Github Actions but GITHUB_TOKEN is not set. Add '
'"env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}" to your '
'step config.',
)
raise CoverallsException(
'Not on TravisCI. You have to provide either repo_token in '
f'{self.config_filename} or set the COVERALLS_REPO_TOKEN env var.',
)
def load_config(self, kwargs, service_name):
"""
Loads all coveralls configuration in the following precedence order.
1. automatic CI configuration
2. COVERALLS_* env vars
3. .coveralls.yml config file
4. CLI flags
"""
self.load_config_from_ci_environment()
self.load_config_from_environment()
self.load_config_from_file()
self.config.update(kwargs)
if self.config.get('coveralls_host'):
# N.B. users can set --coveralls-host via CLI, but we don't keep
# that in the config
self._coveralls_host = self.config.pop('coveralls_host')
if service_name:
self.config['service_name'] = service_name
@staticmethod
def load_config_from_appveyor():
pr = os.environ.get('APPVEYOR_PULL_REQUEST_NUMBER')
return 'appveyor', os.environ.get('APPVEYOR_BUILD_ID'), None, pr
@staticmethod
def load_config_from_buildkite():
pr = os.environ.get('BUILDKITE_PULL_REQUEST')
if pr == 'false':
pr = None
return 'buildkite', os.environ.get('BUILDKITE_JOB_ID'), None, pr
@staticmethod
def load_config_from_circle():
number = (
os.environ.get('CIRCLE_WORKFLOW_ID')
or os.environ.get('CIRCLE_BUILD_NUM')
)
pr = (os.environ.get('CI_PULL_REQUEST') or '').split('/')[-1] or None
job = os.environ.get('CIRCLE_NODE_INDEX')
return 'circleci', job, number, pr
def load_config_from_github(self):
# See https://github.com/lemurheavy/coveralls-public/issues/1710
# Github tokens and standard Coveralls tokens are almost but not quite
# the same -- forceibly using Github's flow seems to be more stable
self.config['repo_token'] = os.environ.get('GITHUB_TOKEN')
pr = None
if os.environ.get('GITHUB_REF', '').startswith('refs/pull/'):
pr = os.environ.get('GITHUB_REF', '//').split('/')[2]
# TODO: coveralls suggests using the RUN_ID for both these values:
# https://github.com/lemurheavy/coveralls-public/issues/1710#issuecomment-1539203555
# However, they also suggest following this successful config approach:
# https://github.com/lemurheavy/coveralls-public/issues/1710#issuecomment-1913696022
# which instead sets:
# COVERALLS_SERVICE_JOB_ID: $GITHUB_RUN_ID
# COVERALLS_SERVICE_NUMBER: $GITHUB_WORKFLOW-$GITHUB_RUN_NUMBER
# should we do the same?
job = os.environ.get('GITHUB_RUN_ID')
number = os.environ.get('GITHUB_RUN_ID')
# N.B. per Coveralls:
# > When you want to identify the repo at Coveralls by its
# > GITHUB_TOKEN, you should choose github, and when you want to
# > identify it by its Coveralls Repo Token, you should choose
# > github-action.
# https://github.com/lemurheavy/coveralls-public/issues/1710#issuecomment-1539203555
return 'github', job, number, pr
@staticmethod
def load_config_from_jenkins():
pr = os.environ.get('CI_PULL_REQUEST', '').split('/')[-1] or None
return 'jenkins', os.environ.get('BUILD_NUMBER'), None, pr
@staticmethod
def load_config_from_travis():
pr = os.environ.get('TRAVIS_PULL_REQUEST')
return 'travis-ci', os.environ.get('TRAVIS_JOB_ID'), None, pr
@staticmethod
def load_config_from_semaphore():
job = (
os.environ.get('SEMAPHORE_JOB_UUID') # Classic
or os.environ.get('SEMAPHORE_JOB_ID') # 2.0
)
number = (
os.environ.get('SEMAPHORE_EXECUTABLE_UUID') # Classic
or os.environ.get('SEMAPHORE_WORKFLOW_ID') # 2.0
)
pr = (
os.environ.get('SEMAPHORE_BRANCH_ID') # Classic
or os.environ.get('SEMAPHORE_GIT_PR_NUMBER') # 2.0
)
return 'semaphore-ci', job, number, pr
@staticmethod
def load_config_from_unknown():
return 'coveralls-python', None, None, None
def load_config_from_generic_ci_environment(self):
# Inspired by the official client:
# coveralls-ruby in lib/coveralls/configuration.rb
# (set_standard_service_params_for_generic_ci)
# The meaning of each env var is clarified in:
# https://github.com/lemurheavy/coveralls-public/issues/1558
config = {
'service_name': os.environ.get('CI_NAME'),
'service_number': os.environ.get('CI_BUILD_NUMBER'),
'service_build_url': os.environ.get('CI_BUILD_URL'),
'service_job_id': os.environ.get('CI_JOB_ID'),
'service_branch': os.environ.get('CI_BRANCH'),
}
pr_match = NUMBER_REGEX.findall(os.environ.get('CI_PULL_REQUEST', ''))
if pr_match:
config['service_pull_request'] = pr_match[-1]
non_empty = {key: value for key, value in config.items() if value}
self.config.update(non_empty)
def load_config_from_ci_environment(self):
# pylint: disable=too-complex
# As defined at the bottom of
# https://docs.coveralls.io/supported-ci-services
# there are a few env vars that should support any arbitrary CI.
# We load them first and allow more specific vars to overwrite
self.load_config_from_generic_ci_environment()
if os.environ.get('APPVEYOR'):
name, job, number, pr = self.load_config_from_appveyor()
elif os.environ.get('BUILDKITE'):
name, job, number, pr = self.load_config_from_buildkite()
elif os.environ.get('CIRCLECI'):
name, job, number, pr = self.load_config_from_circle()
elif os.environ.get('GITHUB_ACTIONS'):
name, job, number, pr = self.load_config_from_github()
elif os.environ.get('JENKINS_HOME'):
name, job, number, pr = self.load_config_from_jenkins()
elif os.environ.get('TRAVIS'):
self._token_required = False
name, job, number, pr = self.load_config_from_travis()
elif os.environ.get('SEMAPHORE'):
name, job, number, pr = self.load_config_from_semaphore()
else:
name, job, number, pr = self.load_config_from_unknown()
self.config.setdefault('service_name', name)
if job:
self.config['service_job_id'] = job
if number:
self.config['service_number'] = number
if pr:
self.config['service_pull_request'] = pr
def load_config_from_environment(self):
coveralls_host = os.environ.get('COVERALLS_HOST')
if coveralls_host:
self._coveralls_host = coveralls_host
parallel = os.environ.get('COVERALLS_PARALLEL', '').lower() == 'true'
if parallel:
self.config['parallel'] = parallel
fields = {
'COVERALLS_FLAG_NAME': 'flag_name',
'COVERALLS_REPO_TOKEN': 'repo_token',
'COVERALLS_SERVICE_JOB_ID': 'service_job_id',
'COVERALLS_SERVICE_JOB_NUMBER': 'service_job_number',
'COVERALLS_SERVICE_NAME': 'service_name',
'COVERALLS_SERVICE_NUMBER': 'service_number',
}
for var, key in fields.items():
value = os.environ.get(var)
if value:
self.config[key] = value
def load_config_from_file(self):
try:
fname = os.path.join(os.getcwd(), self.config_filename)
with open(fname) as config:
try:
import yaml # pylint: disable=import-outside-toplevel
self.config.update(yaml.safe_load(config))
except ImportError:
log.warning(
'PyYAML is not installed, skipping %s.',
self.config_filename,
)
except OSError:
log.debug(
'Missing %s file. Using only env variables.',
self.config_filename,
)
def merge(self, path):
reader = codecs.getreader('utf-8')
with open(path, 'rb') as fh:
extra = json.load(reader(fh))
self.create_data(extra)
def wear(self, dry_run=False):
json_string = self.create_report()
if dry_run:
return {}
return self.submit_report(json_string)
def submit_report(self, json_string):
endpoint = f'{self._coveralls_host.rstrip("/")}/api/v1/jobs'
verify = not bool(os.environ.get('COVERALLS_SKIP_SSL_VERIFY'))
response = requests.post(
endpoint, files={'json_file': json_string}, verify=verify,
)
if response.status_code == 422:
if self.config['service_name'].startswith('github'):
print(
'Received 422 submitting job via Github Actions. By '
'default, coveralls-python uses the "github" service '
'name, which requires you to set the $GITHUB_TOKEN '
'environment variable. If you want to use a '
'COVERALLS_REPO_TOKEN instead, please manually override '
'$COVERALLS_SERVICE_NAME to "github-actions". For more '
'info, see https://coveralls-python.readthedocs.io/en'
'/latest/usage/configuration.html#github-actions-support',
)
try:
response.raise_for_status()
data = response.json()
except Exception as e:
raise CoverallsException(
f'Could not submit coverage: {e}',
) from e
return data
# https://docs.coveralls.io/parallel-build-webhook
def parallel_finish(self):
payload = {'payload': {'status': 'done'}}
# required args
if self.config.get('repo_token'):
payload['repo_token'] = self.config['repo_token']
if self.config.get('service_number'):
payload['payload']['build_num'] = self.config['service_number']
# service-specific parameters
if os.environ.get('GITHUB_REPOSITORY'):
# Github Actions only
payload['repo_name'] = os.environ.get('GITHUB_REPOSITORY')
endpoint = f'{self._coveralls_host.rstrip("/")}/webhook'
verify = not bool(os.environ.get('COVERALLS_SKIP_SSL_VERIFY'))
response = requests.post(endpoint, json=payload, verify=verify)
try:
response.raise_for_status()
response = response.json()
except Exception as e:
raise CoverallsException(
f'Parallel finish failed: {e}',
) from e
if 'error' in response:
e = response['error']
raise CoverallsException(f'Parallel finish failed: {e}')
if 'done' not in response or not response['done']:
raise CoverallsException('Parallel finish failed')
return response
def create_report(self):
"""Generate json dumped report for coveralls api."""
data = self.create_data()
try:
json_string = json.dumps(data)
except UnicodeDecodeError:
log.exception('ERROR: While preparing JSON:')
self.debug_bad_encoding(data)
raise
log_string = re.sub(
r'"repo_token": "(.+?)"',
'"repo_token": "[secure]"',
json_string,
)
log.debug(log_string)
log.debug('==\nReporting %s files\n==\n', len(data['source_files']))
for source_file in data['source_files']:
log.debug(
'%s - %d/%d', source_file['name'],
sum(filter(None, source_file['coverage'])),
len(source_file['coverage']),
)
return json_string
def save_report(self, file_path):
"""Write coveralls report to file."""
try:
report = self.create_report()
except coverage.CoverageException:
log.exception('Failure to gather coverage:')
else:
with open(file_path, 'w') as report_file:
report_file.write(report)
def create_data(self, extra=None):
r"""
Generate object for api.
Example json:
{
"service_job_id": "1234567890",
"service_name": "travis-ci",
"source_files": [
{
"name": "example.py",
"source": "def four\n 4\nend",
"coverage": [null, 1, null]
},
{
"name": "two.py",
"source": "def seven\n eight\n nine\nend",
"coverage": [null, 1, 0, null]
}
],
"parallel": True
}
"""
if self._data:
return self._data
self._data = {'source_files': self.get_coverage()}
self._data.update(git_info())
self._data.update(self.config)
if extra:
if 'source_files' in extra:
self._data['source_files'].extend(extra['source_files'])
else:
log.warning(
'No data to be merged; does the json file contain '
'"source_files" data?',
)
return self._data
def get_coverage(self):
config_file = self.config.get('config_file', True)
work = coverage.coverage(config_file=config_file)
work.load()
work.get_data()
base_dir = self.config.get('base_dir') or ''
src_dir = self.config.get('src_dir') or ''
return CoverallReporter(work, base_dir, src_dir).coverage
@staticmethod
def debug_bad_encoding(data):
"""Let's try to help user figure out what is at fault."""
at_fault_files = set()
for source_file_data in data['source_files']:
for value in source_file_data.values():
try:
json.dumps(value)
except UnicodeDecodeError:
at_fault_files.add(source_file_data['name'])
if at_fault_files:
log.error(
'HINT: Following files cannot be decoded properly into '
'unicode. Check their content: %s',
', '.join(at_fault_files),
)