Skip to content

Commit

Permalink
github actions on py26; make coveralls work
Browse files Browse the repository at this point in the history
The files coveralls-api.py27 and coveralls-api.py3 are an ugly hack to
workaround a bug in the coveralls library with respect to Github
Actions.  The payload sent to coveralls.io must contain
`{"service_job_id":null, "service_name": "github"}`. By default, the
coveralls library omits `service_job_id` and uses `github-actions`.
These are both wrong.  See
https://github.com/AndreMiras/coveralls-python-action/blob/develop/src/entrypoint.py#L57-L65

Also add support for py26 with tox 2
  • Loading branch information
richm committed Dec 9, 2020
1 parent b113fd8 commit fc78733
Show file tree
Hide file tree
Showing 5 changed files with 693 additions and 8 deletions.
26 changes: 18 additions & 8 deletions .github/workflows/tox.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,28 @@ jobs:
run: |
set -euxo pipefail
python -m pip install --upgrade pip
pip install -rtox_requirements.txt
pip install -rtox-requirements.txt
- name: Run tox tests
run: |
set -euxo pipefail
tox
toxpyver=$(echo "${{ matrix.pyver }}" | tr -d .)
# prime the env to create the venv
toxpyenv="py${toxpyver}-tox30"
tox -q --notest -e "$toxpyenv"
# copy in the coveralls fix
# the code for handling github-actions in coveralls is broken - you
# must add service_job_id:null to the payload, and use service_name:github
# instead of service_name:github-actions, otherwise, coveralls.io will return
# error 422
case "$toxpyver" in
2*) cp coveralls-api.py27 ".tox/$toxpyenv/lib/python${{ matrix.pyver }}/site-packages/coveralls/api.py" ;;
3*) cp coveralls-api.py3 ".tox/$toxpyenv/lib/python${{ matrix.pyver }}/site-packages/coveralls/api.py" ;;
esac
tox -e "black,isort,pylint,flake8,bandit,pydocstyle,$toxpyenv"
env:
COVERALLS_CMD: coveralls
COVERALLS_PARALLEL: "true"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
python-26:
runs-on: ubuntu-16.04
steps:
Expand All @@ -41,7 +55,7 @@ jobs:
sudo chown -R $myuid:$mygid /home/travis/virtualenv
source /home/travis/virtualenv/python2.6/bin/activate
set -x
pip install -rtox_requirements.txt
pip install -rtox-requirements.txt
env:
# yamllint disable-line rule:line-length
PY26URL: https://storage.googleapis.com/travis-ci-language-archives/python/binaries/ubuntu/14.04/x86_64/python-2.6.tar.bz2
Expand All @@ -56,7 +70,3 @@ jobs:
COVERALLS_CMD: "echo skipping coveralls"
SAFETY_CMD: "echo skipping safety"
VIRTUAL_ENV_DISABLE_PROMPT: "true"
- name: Coveralls
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ the plugin is being used:
black
pylint
flake8
yamllint
...
```
If the plugin is not enabled, you would only see testenv definitions in your
Expand Down Expand Up @@ -304,3 +305,12 @@ lsr_some_shell_func ....
etc.
* `LSR_TOX_ENV_DIR` - the full path to the directory for the test environment
e.g. `/path/to/ROLENAME/.tox/env-3.8`

## About the Coveralls Hack

The files coveralls-api.py27 and coveralls-api.py3 are an ugly hack to
workaround a bug in the coveralls library with respect to Github Actions. The
payload sent to coveralls.io must contain `{"service_job_id":null,
"service_name": "github"}`. By default, the coveralls library omits
`service_job_id` and uses `github-actions`. These are both wrong. See
https://github.com/AndreMiras/coveralls-python-action/blob/develop/src/entrypoint.py#L57-L65
296 changes: 296 additions & 0 deletions coveralls-api.py27
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
# coding: utf-8
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')


class Coveralls(object):
config_filename = '.coveralls.yml'

def __init__(self, token_required=True, service_name=None, **kwargs):
"""
* 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_from_file()
self.config.update(kwargs)
self.config['service_job_id'] = None
service_name = 'github'
if service_name:
self.config['service_name'] = service_name
if self.config.get('coveralls_host'):
self._coveralls_host = self.config['coveralls_host']
del self.config['coveralls_host']

self.load_config_from_environment()

name, job, pr = self.load_config_from_ci_environment()
self.config['service_name'] = self.config.get('service_name', name)
if job:
# N.B. Github Actions uses a different chunk of the Coveralls
# config when running parallel builds, ie. `service_number` instead
# of `service_job_id`.
if name.startswith('github'):
self.config['service_number'] = job
else:
self.config['service_job_id'] = job
if pr:
self.config['service_pull_request'] = pr

log.debug('constructor name {} id {}'.format(self.config['service_name'], self.config['service_job_id']))
self.ensure_token()

def ensure_token(self):
if self.config.get('repo_token') or not self._token_required:
return

raise CoverallsException(
'Not on TravisCI. You have to provide either repo_token in {} or '
'set the COVERALLS_REPO_TOKEN env var.'.format(
self.config_filename))

@staticmethod
def load_config_from_appveyor():
pr = os.environ.get('APPVEYOR_PULL_REQUEST_NUMBER')
return 'appveyor', os.environ.get('APPVEYOR_BUILD_ID'), 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'), pr

@staticmethod
def load_config_from_circle():
pr = os.environ.get('CI_PULL_REQUEST', '').split('/')[-1] or None
return 'circle-ci', os.environ.get('CIRCLE_BUILD_NUM'), pr

@staticmethod
def load_config_from_github():
service_number = os.environ.get('GITHUB_SHA')
pr = None
if os.environ.get('GITHUB_REF', '').startswith('refs/pull/'):
pr = os.environ.get('GITHUB_REF', '//').split('/')[2]
service_number += '-PR-{0}'.format(pr)
return 'github-actions', service_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'), pr

@staticmethod
def load_config_from_travis():
pr = os.environ.get('TRAVIS_PULL_REQUEST')
return 'travis-ci', os.environ.get('TRAVIS_JOB_ID'), pr

@staticmethod
def load_config_from_semaphore():
pr = os.environ.get('PULL_REQUEST_NUMBER')
return 'semaphore-ci', os.environ.get('SEMAPHORE_BUILD_NUMBER'), pr

@staticmethod
def load_config_from_unknown():
return 'coveralls-python', None, None

def load_config_from_ci_environment(self):
if os.environ.get('APPVEYOR'):
name, job, pr = self.load_config_from_appveyor()
elif os.environ.get('BUILDKITE'):
name, job, pr = self.load_config_from_buildkite()
elif os.environ.get('CIRCLECI'):
name, job, pr = self.load_config_from_circle()
elif os.environ.get('GITHUB_ACTIONS'):
name, job, pr = self.load_config_from_github()
elif os.environ.get('JENKINS_HOME'):
name, job, pr = self.load_config_from_jenkins()
elif os.environ.get('TRAVIS'):
self._token_required = False
name, job, pr = self.load_config_from_travis()
elif os.environ.get('SEMAPHORE'):
name, job, pr = self.load_config_from_semaphore()
else:
name, job, pr = self.load_config_from_unknown()
return (name, job, 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

repo_token = os.environ.get('COVERALLS_REPO_TOKEN')
if repo_token:
self.config['repo_token'] = repo_token

service_name = os.environ.get('COVERALLS_SERVICE_NAME')
if service_name:
self.config['service_name'] = service_name

flag_name = os.environ.get('COVERALLS_FLAG_NAME')
if flag_name:
self.config['flag_name'] = flag_name

def load_config_from_file(self):
try:
with open(os.path.join(os.getcwd(),
self.config_filename)) as config:
try:
import yaml
return yaml.safe_load(config)
except ImportError:
log.warning('PyYAML is not installed, skipping %s.',
self.config_filename)
except (OSError, IOError):
log.debug('Missing %s file. Using only env variables.',
self.config_filename)

return {}

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 {}

endpoint = '{}/api/v1/jobs'.format(self._coveralls_host.rstrip('/'))
verify = not bool(os.environ.get('COVERALLS_SKIP_SSL_VERIFY'))
response = requests.post(endpoint, files={'json_file': json_string},
verify=verify)
log.debug("endpoint {} response headers {} body {}".format(endpoint, response.headers, response.json()))
try:
response.raise_for_status()
return response.json()
except Exception as e:
raise CoverallsException('Could not submit coverage: {}'.format(e))

def create_report(self):
"""Generate json dumped report for coveralls api."""
data = self.create_data()
try:
json_string = json.dumps(data)
except UnicodeDecodeError as e:
log.error('ERROR: While preparing JSON:', exc_info=e)
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 - %s/%s', 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 as e:
log.error('Failure to gather coverage:', exc_info=e)
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)
workman = coverage.coverage(config_file=config_file)
workman.load()

if hasattr(workman, '_harvest_data'):
workman._harvest_data() # pylint: disable=W0212
else:
workman.get_data()

return CoverallReporter(workman, workman.config).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))
Loading

0 comments on commit fc78733

Please sign in to comment.