|
| 1 | +import codecs |
| 2 | +import json |
| 3 | +import logging |
| 4 | +import os |
| 5 | +import re |
| 6 | + |
| 7 | +import coverage |
| 8 | +import requests |
| 9 | + |
| 10 | +from .exception import CoverallsException |
| 11 | +from .git import git_info |
| 12 | +from .reporter import CoverallReporter |
| 13 | + |
| 14 | + |
| 15 | +log = logging.getLogger('coveralls.api') |
| 16 | +log.addHandler(logging.StreamHandler()) |
| 17 | +log.setLevel(logging.DEBUG) |
| 18 | + |
| 19 | + |
| 20 | +class Coveralls: |
| 21 | + config_filename = '.coveralls.yml' |
| 22 | + |
| 23 | + def __init__(self, token_required=True, service_name=None, **kwargs): |
| 24 | + """ |
| 25 | + Initialize the main Coveralls collection entrypoint. |
| 26 | +
|
| 27 | + * repo_token |
| 28 | + The secret token for your repository, found at the bottom of your |
| 29 | + repository's page on Coveralls. |
| 30 | +
|
| 31 | + * service_name |
| 32 | + The CI service or other environment in which the test suite was run. |
| 33 | + This can be anything, but certain services have special features |
| 34 | + (travis-ci, travis-pro, or coveralls-ruby). |
| 35 | +
|
| 36 | + * [service_job_id] |
| 37 | + A unique identifier of the job on the service specified by |
| 38 | + service_name. |
| 39 | + """ |
| 40 | + self._data = None |
| 41 | + self._coveralls_host = 'https://coveralls.io/' |
| 42 | + self._token_required = token_required |
| 43 | + |
| 44 | + self.config = self.load_config_from_file() |
| 45 | + self.config.update(kwargs) |
| 46 | + if service_name: |
| 47 | + self.config['service_name'] = service_name |
| 48 | + if self.config.get('coveralls_host'): |
| 49 | + self._coveralls_host = self.config['coveralls_host'] |
| 50 | + del self.config['coveralls_host'] |
| 51 | + |
| 52 | + self.load_config_from_environment() |
| 53 | + |
| 54 | + name, job, pr = self.load_config_from_ci_environment() |
| 55 | + log.info(f"name: {name}, job: {job}, pr: {pr}") |
| 56 | + self.config['service_name'] = self.config.get('service_name', name) |
| 57 | + if job: |
| 58 | + # N.B. Github Actions uses a different chunk of the Coveralls |
| 59 | + # config when running parallel builds, ie. `service_number` instead |
| 60 | + # of `service_job_id`. |
| 61 | + if name.startswith('github'): |
| 62 | + self.config['service_number'] = job |
| 63 | + else: |
| 64 | + self.config['service_job_id'] = job |
| 65 | + if pr: |
| 66 | + self.config['service_pull_request'] = pr |
| 67 | + |
| 68 | + self.ensure_token() |
| 69 | + |
| 70 | + def ensure_token(self): |
| 71 | + if self.config.get('repo_token') or not self._token_required: |
| 72 | + return |
| 73 | + |
| 74 | + raise CoverallsException( |
| 75 | + 'Not on TravisCI. You have to provide either repo_token in {} or ' |
| 76 | + 'set the COVERALLS_REPO_TOKEN env var.'.format( |
| 77 | + self.config_filename)) |
| 78 | + |
| 79 | + @staticmethod |
| 80 | + def load_config_from_appveyor(): |
| 81 | + pr = os.environ.get('APPVEYOR_PULL_REQUEST_NUMBER') |
| 82 | + return 'appveyor', os.environ.get('APPVEYOR_BUILD_ID'), pr |
| 83 | + |
| 84 | + @staticmethod |
| 85 | + def load_config_from_buildkite(): |
| 86 | + pr = os.environ.get('BUILDKITE_PULL_REQUEST') |
| 87 | + if pr == 'false': |
| 88 | + pr = None |
| 89 | + return 'buildkite', os.environ.get('BUILDKITE_JOB_ID'), pr |
| 90 | + |
| 91 | + @staticmethod |
| 92 | + def load_config_from_circle(): |
| 93 | + pr = os.environ.get('CI_PULL_REQUEST', '').split('/')[-1] or None |
| 94 | + return 'circle-ci', os.environ.get('CIRCLE_BUILD_NUM'), pr |
| 95 | + |
| 96 | + @staticmethod |
| 97 | + def load_config_from_github(): |
| 98 | + service_number = os.environ.get('GITHUB_SHA') |
| 99 | + pr = None |
| 100 | + if os.environ.get('GITHUB_REF', '').startswith('refs/pull/'): |
| 101 | + pr = os.environ.get('GITHUB_REF', '//').split('/')[2] |
| 102 | + service_number += '-PR-{}'.format(pr) |
| 103 | + log.info(f"load_config_from_github service_number: {service_number}, pr: {pr}") |
| 104 | + return 'github-actions', service_number, pr |
| 105 | + |
| 106 | + @staticmethod |
| 107 | + def load_config_from_jenkins(): |
| 108 | + pr = os.environ.get('CI_PULL_REQUEST', '').split('/')[-1] or None |
| 109 | + return 'jenkins', os.environ.get('BUILD_NUMBER'), pr |
| 110 | + |
| 111 | + @staticmethod |
| 112 | + def load_config_from_travis(): |
| 113 | + pr = os.environ.get('TRAVIS_PULL_REQUEST') |
| 114 | + return 'travis-ci', os.environ.get('TRAVIS_JOB_ID'), pr |
| 115 | + |
| 116 | + @staticmethod |
| 117 | + def load_config_from_semaphore(): |
| 118 | + pr = os.environ.get('PULL_REQUEST_NUMBER') |
| 119 | + return 'semaphore-ci', os.environ.get('SEMAPHORE_BUILD_NUMBER'), pr |
| 120 | + |
| 121 | + @staticmethod |
| 122 | + def load_config_from_unknown(): |
| 123 | + return 'coveralls-python', None, None |
| 124 | + |
| 125 | + def load_config_from_ci_environment(self): |
| 126 | + log.info("os.environ:") |
| 127 | + log.info(os.environ) |
| 128 | + if os.environ.get('APPVEYOR'): |
| 129 | + name, job, pr = self.load_config_from_appveyor() |
| 130 | + elif os.environ.get('BUILDKITE'): |
| 131 | + name, job, pr = self.load_config_from_buildkite() |
| 132 | + elif os.environ.get('CIRCLECI'): |
| 133 | + name, job, pr = self.load_config_from_circle() |
| 134 | + elif os.environ.get('GITHUB_ACTIONS'): |
| 135 | + name, job, pr = self.load_config_from_github() |
| 136 | + elif os.environ.get('JENKINS_HOME'): |
| 137 | + name, job, pr = self.load_config_from_jenkins() |
| 138 | + elif os.environ.get('TRAVIS'): |
| 139 | + self._token_required = False |
| 140 | + name, job, pr = self.load_config_from_travis() |
| 141 | + elif os.environ.get('SEMAPHORE'): |
| 142 | + name, job, pr = self.load_config_from_semaphore() |
| 143 | + else: |
| 144 | + name, job, pr = self.load_config_from_unknown() |
| 145 | + return (name, job, pr) |
| 146 | + |
| 147 | + def load_config_from_environment(self): |
| 148 | + coveralls_host = os.environ.get('COVERALLS_HOST') |
| 149 | + if coveralls_host: |
| 150 | + self._coveralls_host = coveralls_host |
| 151 | + |
| 152 | + parallel = os.environ.get('COVERALLS_PARALLEL', '').lower() == 'true' |
| 153 | + if parallel: |
| 154 | + self.config['parallel'] = parallel |
| 155 | + |
| 156 | + repo_token = os.environ.get('COVERALLS_REPO_TOKEN') |
| 157 | + if repo_token: |
| 158 | + self.config['repo_token'] = repo_token |
| 159 | + |
| 160 | + service_name = os.environ.get('COVERALLS_SERVICE_NAME') |
| 161 | + if service_name: |
| 162 | + self.config['service_name'] = service_name |
| 163 | + |
| 164 | + flag_name = os.environ.get('COVERALLS_FLAG_NAME') |
| 165 | + if flag_name: |
| 166 | + self.config['flag_name'] = flag_name |
| 167 | + |
| 168 | + def load_config_from_file(self): |
| 169 | + try: |
| 170 | + with open(os.path.join(os.getcwd(), |
| 171 | + self.config_filename)) as config: |
| 172 | + try: |
| 173 | + import yaml # pylint: disable=import-outside-toplevel |
| 174 | + return yaml.safe_load(config) |
| 175 | + except ImportError: |
| 176 | + log.warning('PyYAML is not installed, skipping %s.', |
| 177 | + self.config_filename) |
| 178 | + except OSError: |
| 179 | + log.debug('Missing %s file. Using only env variables.', |
| 180 | + self.config_filename) |
| 181 | + |
| 182 | + return {} |
| 183 | + |
| 184 | + def merge(self, path): |
| 185 | + reader = codecs.getreader('utf-8') |
| 186 | + with open(path, 'rb') as fh: |
| 187 | + extra = json.load(reader(fh)) |
| 188 | + self.create_data(extra) |
| 189 | + |
| 190 | + def wear(self, dry_run=False): |
| 191 | + json_string = self.create_report() |
| 192 | + if dry_run: |
| 193 | + return {} |
| 194 | + |
| 195 | + endpoint = '{}/api/v1/jobs'.format(self._coveralls_host.rstrip('/')) |
| 196 | + verify = not bool(os.environ.get('COVERALLS_SKIP_SSL_VERIFY')) |
| 197 | + response = requests.post(endpoint, files={'json_file': json_string}, |
| 198 | + verify=verify) |
| 199 | + try: |
| 200 | + response.raise_for_status() |
| 201 | + return response.json() |
| 202 | + except Exception as e: |
| 203 | + raise CoverallsException('Could not submit coverage: {}'.format(e)) |
| 204 | + |
| 205 | + def create_report(self): |
| 206 | + """Generate json dumped report for coveralls api.""" |
| 207 | + data = self.create_data() |
| 208 | + try: |
| 209 | + json_string = json.dumps(data) |
| 210 | + except UnicodeDecodeError as e: |
| 211 | + log.error('ERROR: While preparing JSON:', exc_info=e) |
| 212 | + self.debug_bad_encoding(data) |
| 213 | + raise |
| 214 | + |
| 215 | + log_string = re.sub(r'"repo_token": "(.+?)"', |
| 216 | + '"repo_token": "[secure]"', json_string) |
| 217 | + log.debug(log_string) |
| 218 | + log.debug('==\nReporting %s files\n==\n', len(data['source_files'])) |
| 219 | + for source_file in data['source_files']: |
| 220 | + log.debug('%s - %s/%s', source_file['name'], |
| 221 | + sum(filter(None, source_file['coverage'])), |
| 222 | + len(source_file['coverage'])) |
| 223 | + return json_string |
| 224 | + |
| 225 | + def save_report(self, file_path): |
| 226 | + """Write coveralls report to file.""" |
| 227 | + try: |
| 228 | + report = self.create_report() |
| 229 | + except coverage.CoverageException as e: |
| 230 | + log.error('Failure to gather coverage:', exc_info=e) |
| 231 | + else: |
| 232 | + with open(file_path, 'w') as report_file: |
| 233 | + report_file.write(report) |
| 234 | + |
| 235 | + def create_data(self, extra=None): |
| 236 | + r""" |
| 237 | + Generate object for api. |
| 238 | +
|
| 239 | + Example json: |
| 240 | + { |
| 241 | + "service_job_id": "1234567890", |
| 242 | + "service_name": "travis-ci", |
| 243 | + "source_files": [ |
| 244 | + { |
| 245 | + "name": "example.py", |
| 246 | + "source": "def four\n 4\nend", |
| 247 | + "coverage": [null, 1, null] |
| 248 | + }, |
| 249 | + { |
| 250 | + "name": "two.py", |
| 251 | + "source": "def seven\n eight\n nine\nend", |
| 252 | + "coverage": [null, 1, 0, null] |
| 253 | + } |
| 254 | + ], |
| 255 | + "parallel": True |
| 256 | + } |
| 257 | + """ |
| 258 | + if self._data: |
| 259 | + return self._data |
| 260 | + |
| 261 | + self._data = {'source_files': self.get_coverage()} |
| 262 | + self._data.update(git_info()) |
| 263 | + self._data.update(self.config) |
| 264 | + if extra: |
| 265 | + if 'source_files' in extra: |
| 266 | + self._data['source_files'].extend(extra['source_files']) |
| 267 | + else: |
| 268 | + log.warning('No data to be merged; does the json file contain ' |
| 269 | + '"source_files" data?') |
| 270 | + |
| 271 | + return self._data |
| 272 | + |
| 273 | + def get_coverage(self): |
| 274 | + config_file = self.config.get('config_file', True) |
| 275 | + workman = coverage.coverage(config_file=config_file) |
| 276 | + workman.load() |
| 277 | + workman.get_data() |
| 278 | + |
| 279 | + return CoverallReporter(workman, workman.config).coverage |
| 280 | + |
| 281 | + @staticmethod |
| 282 | + def debug_bad_encoding(data): |
| 283 | + """Let's try to help user figure out what is at fault.""" |
| 284 | + at_fault_files = set() |
| 285 | + for source_file_data in data['source_files']: |
| 286 | + for value in source_file_data.values(): |
| 287 | + try: |
| 288 | + json.dumps(value) |
| 289 | + except UnicodeDecodeError: |
| 290 | + at_fault_files.add(source_file_data['name']) |
| 291 | + |
| 292 | + if at_fault_files: |
| 293 | + log.error('HINT: Following files cannot be decoded properly into ' |
| 294 | + 'unicode. Check their content: %s', |
| 295 | + ', '.join(at_fault_files)) |
0 commit comments