diff --git a/.gitignore b/.gitignore index ecb2893..527f6bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,105 @@ -*.py[cdo] +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ build/ +develop-eggs/ dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ *.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ .coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio + +*.iml + +## Directory-based project format: +.idea/ +# if you remove the above rule, at least ignore the following: + +# User-specific stuff: +# .idea/workspace.xml +# .idea/tasks.xml +# .idea/dictionaries + +# Sensitive or high-churn files: +# .idea/dataSources.ids +# .idea/dataSources.xml +# .idea/sqlDataSources.xml +# .idea/dynamic.xml +# .idea/uiDesigner.xml + +# Gradle: +# .idea/gradle.xml +# .idea/libraries + +# Mongo Explorer plugin: +# .idea/mongoSettings.xml + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5ed5354 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +all: build test install + +build: + python setup.py build + +install: build + python setup.py develop + +test: pep8 pyflakes + sed 's?\$$1?'`pwd`'?' tests/filepath/cobertura.xml.tpl > tests/filepath/cobertura.xml + python setup.py test + rm tests/filepath/cobertura.xml || true + +test-all: + tox + +coverage: + rm coverage.xml || true + sed 's?\$$1?'`pwd`'?' tests/filepath/cobertura.xml.tpl > tests/filepath/cobertura.xml + coverage run --source src/codacy/ setup.py test + rm tests/filepath/cobertura.xml || true + coverage xml + python-codacy-coverage -r coverage.xml + +# requires "pip install pep8" +pep8: + @git ls-files | grep \\.py$ | xargs pep8 + +# requires "pip install pyflakes" +pyflakes: + @export PYFLAKES_NODOCTEST=1 && \ + git ls-files | grep \\.py$ | xargs pyflakes + +upload: + python setup.py sdist bdist_wheel upload diff --git a/README.md b/README.md deleted file mode 100644 index ccead75..0000000 --- a/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# python-codacycov - -Submit Python `coverage` report to [Codacy](https://www.codacy.com/). - -DISCLAIMER: This is an unofficial project, and is not endorsed by or -associated with Codacy in any way. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..fd88aa1 --- /dev/null +++ b/README.rst @@ -0,0 +1,46 @@ +python-codacy-coverage +====================== + +Credits to Ryan for creating this! Python coverage reporter for Codacy https://www.codacy.com + +.. image:: https://api.codacy.com/project/badge/grade/3a8cf06a9db94d0ab3d55e0357bc8f9d + :target: https://www.codacy.com/app/Codacy/python-codacy-coverage + :alt: Codacy Badge +.. image:: https://api.codacy.com/project/badge/coverage/3a8cf06a9db94d0ab3d55e0357bc8f9d + :target: https://www.codacy.com/app/Codacy/python-codacy-coverage + :alt: Codacy Badge +.. image:: https://circleci.com/gh/codacy/python-codacy-coverage.png?style=shield&circle-token=:circle-token + :target: https://circleci.com/gh/codacy/python-codacy-coverage + :alt: Build Status +.. image:: https://badge.fury.io/py/codacy-coverage.svg + :target: https://badge.fury.io/py/codacy-coverage + :alt: PyPI version + +Setup +----- + +Codacy assumes that coverage is previously configured for your project. + +To generate the required coverage XML file, calculate coverage for your project as normal, by running: + +``coverage xml`` + +Install codacy-coverage +~~~~~~~~~~~~~~~~~~~~~~~ + +You can install the coverage reporter by running: + +``pip install codacy-coverage`` + +Updating Codacy +--------------- + +To update Codacy, you will need your project API token. You can find the token in Project -> Settings -> Integrations -> Project API. + +Then set it in your terminal, replacing %Project_Token% with your own token: + +``export CODACY_PROJECT_TOKEN=%Project_Token%`` + +Next, simply run the Codacy reporter. It will find the current commit and send all details to your project dashboard: + +``python-codacy-coverage -r coverage.xml`` diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..9ded75f --- /dev/null +++ b/circle.yml @@ -0,0 +1,16 @@ +machine: + python: + version: 2.7.10 + +dependencies: + post: + - sudo pip install pep8 --upgrade + - sudo pip install pyflakes + - sudo pip install coverage + - sudo pip install virtualenv==12.0.2 + +test: + override: + - make test-all + - make install + - make coverage \ No newline at end of file diff --git a/codacycov.py b/codacycov.py deleted file mode 100644 index f10facf..0000000 --- a/codacycov.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Submit Python coverage report to Codacy.""" - -import sys -from xml.dom import minidom - -import requests - - -XML_DOC = 'coverage.xml' -URL = 'https://www.codacy.com/api/coverage/{token}/{commit}' - - -def main(token, commit, xml_file=XML_DOC): - """Parse XML file and POST it to the Codacy API""" - - # Convert decimal string to floored int percent value - percent = lambda s: int(float(s)*100) - - # Parse the XML into the format expected by the API - xmldoc = minidom.parse(xml_file) - - data = { - 'total': percent(xmldoc.getElementsByTagName('coverage')[0].attributes['line-rate'].value), - 'fileReports': [], - } - - classes = xmldoc.getElementsByTagName('class') - for cls in classes: - file_report = { - 'filename': cls.attributes['filename'].value, - 'total': percent(cls.attributes['line-rate'].value), - 'coverage': {}, - } - lines = cls.getElementsByTagName('line') - for line in lines: - hits = int(line.attributes['hits'].value) - if hits >= 1: - # The API assumes 0 if a line is missing - file_report['coverage'][line.attributes['number'].value] = hits - data['fileReports'] += [file_report] - - # Try to send the data, raise an exception if we fail - r = requests.post(URL.format(token=token, commit=commit), data=data) - r.raise_for_status() - - -if __name__ == '__main__': - argc = len(sys.argv) - if argc < 3: - print("usage: codacycov.py TOKEN COMMIT [COVERAGE_XML]") - print("if COVERAGE_XML is not specified, default is coverage.xml") - elif argc < 4: - main(sys.argv[1], sys.argv[2]) - else: - main(sys.argv[1], sys.argv[2], sys.argv[3]) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f229360..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9b303f5 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,8 @@ +[bdist_wheel] +# This flag says that the code is written to work on both Python 2 and Python +# 3. If at all possible, it is good practice to do this. If you cannot, you +# will need to generate wheels for each Python version that you support. +universal=1 + +[pep8] +max_line_length = 120 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..cacfa58 --- /dev/null +++ b/setup.py @@ -0,0 +1,69 @@ +# Always prefer setuptools over distutils +from setuptools import setup, find_packages +# To use a consistent encoding +from codecs import open +from os import path + +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the relevant file +with open(path.join(here, 'README.rst'), encoding='utf-8') as f: + long_description = f.read() + +# Default version +__version__ = '1.1.1' + +# Get the correct version from file +try: + import version + __version__ = version.__version__ +except ImportError: + pass + +setup( + name='codacy-coverage', + + version=__version__, + + description='Codacy coverage reporter for Python', + long_description=long_description, + + url='https://github.com/codacy/python-codacy-coverage', + + author='Codacy', + author_email='team@codacy.com', + + license='MIT', + + # See https://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + 'Development Status :: 5 - Production/Stable', + + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Build Tools', + + 'License :: OSI Approved :: MIT License', + + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + ], + + keywords='development coverage', + + packages=find_packages('src'), + package_dir={'': 'src'}, include_package_data=True, + + install_requires=['requests'], + + extras_require={ + 'dev': ['check-manifest'], + 'test': ['nosetests', 'coverage'], + }, + + entry_points={ + 'console_scripts': [ + 'python-codacy-coverage=codacy:main', + ], + }, + test_suite='tests' +) diff --git a/src/codacy/__init__.py b/src/codacy/__init__.py new file mode 100644 index 0000000..6068f5c --- /dev/null +++ b/src/codacy/__init__.py @@ -0,0 +1,6 @@ +from __future__ import absolute_import +from . import reporter + + +def main(): + return reporter.run() diff --git a/src/codacy/reporter.py b/src/codacy/reporter.py new file mode 100755 index 0000000..8882e91 --- /dev/null +++ b/src/codacy/reporter.py @@ -0,0 +1,140 @@ +"""Codacy coverage reporter for Python""" + +import argparse +import json +import logging +import os +from xml.dom import minidom +import requests + +logging.basicConfig(level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s') + +CODACY_PROJECT_TOKEN = os.getenv('CODACY_PROJECT_TOKEN') +CODACY_BASE_API_URL = os.getenv('CODACY_API_BASE_URL', 'https://api.codacy.com') +URL = CODACY_BASE_API_URL + '/2.0/coverage/{commit}/python' +DEFAULT_REPORT_FILE = 'coverage.xml' + + +def get_git_revision_hash(): + import subprocess + + return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode("utf-8").strip() + + +def get_git_directory(): + import subprocess + + return subprocess.check_output(['git', 'rev-parse', '--show-toplevel']).decode("utf-8").strip() + + +def file_exists(rootdir, filename): + for root, subFolders, files in os.walk(rootdir): + if filename in files: + return True + else: + for subFolder in subFolders: + return file_exists(subFolder, filename) + return False + + +def generate_filename(sources, filename): + def strip_prefix(line, prefix): + if line.startswith(prefix): + return line[len(prefix):] + else: + return line + + git_directory = get_git_directory() + + for source in sources: + if file_exists(source, filename): + return strip_prefix(source, git_directory).strip("/") + "/" + filename.strip("/") + + return filename + + +def parse_report_file(report_file): + """Parse XML file and POST it to the Codacy API + :param report_file: + """ + + # Convert decimal string to floored int percent value + def percent(s): + return float(s) * 100 + + # Parse the XML into the format expected by the API + report_xml = minidom.parse(report_file) + + report = { + 'language': "python", + 'total': percent(report_xml.getElementsByTagName('coverage')[0].attributes['line-rate'].value), + 'fileReports': [], + } + + sources = [x.firstChild.nodeValue for x in report_xml.getElementsByTagName('source')] + classes = report_xml.getElementsByTagName('class') + for cls in classes: + file_report = { + 'filename': generate_filename(sources, cls.attributes['filename'].value), + 'total': percent(cls.attributes['line-rate'].value), + 'coverage': {}, + } + lines = cls.getElementsByTagName('line') + for line in lines: + hits = int(line.attributes['hits'].value) + if hits >= 1: + # The API assumes 0 if a line is missing + file_report['coverage'][line.attributes['number'].value] = hits + report['fileReports'] += [file_report] + + return report + + +def upload_report(report, token, commit): + """Try to send the data, raise an exception if we fail""" + url = URL.format(commit=commit) + data = json.dumps(report) + headers = { + "project_token": token, + "Content-Type": "application/json" + } + + logging.debug(data) + + r = requests.post(url, data=data, headers=headers, allow_redirects=True) + + logging.debug(r.content) + r.raise_for_status() + + message = json.loads(r.text)['success'] + logging.info(message) + + +def run(): + parser = argparse.ArgumentParser(description='Codacy coverage reporter for Python.') + parser.add_argument("-r", "--report", type=str, help="coverage report file", default=DEFAULT_REPORT_FILE) + parser.add_argument("-c", "--commit", type=str, help="git commit hash") + parser.add_argument("-v", "--verbose", help="show debug information", action="store_true") + + args = parser.parse_args() + + if args.verbose: + logging.Logger.setLevel(logging.getLogger(), logging.DEBUG) + + if not CODACY_PROJECT_TOKEN: + logging.error("environment variable CODACY_PROJECT_TOKEN is not defined.") + exit(1) + + if not args.commit: + args.commit = get_git_revision_hash() + + if not os.path.isfile(args.report): + logging.error("Coverage report " + args.report + " not found.") + exit(1) + + logging.info("Parsing report file...") + report = parse_report_file(args.report) + + logging.info("Uploading report...") + upload_report(report, CODACY_PROJECT_TOKEN, args.commit) diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..a1f14c7 --- /dev/null +++ b/tests.py @@ -0,0 +1,36 @@ +import unittest +import codacy.reporter +import json + + +class ReporterTests(unittest.TestCase): + def compare_parse_result(self, generated_filename, expected_filename): + def file_get_contents(filename): + with open(filename) as f: + return f.read() + + generated = codacy.reporter.parse_report_file(generated_filename) + + json_content = file_get_contents(expected_filename) + expected = json.loads(json_content) + + self.assertEqual(generated, expected) + + def test_parser_coverage3(self): + self.maxDiff = None + + self.compare_parse_result('tests/coverage3/cobertura.xml', 'tests/coverage3/coverage.json') + + def test_parser_coverage4(self): + self.maxDiff = None + + self.compare_parse_result('tests/coverage4/cobertura.xml', 'tests/coverage4/coverage.json') + + def test_parser_git_filepath(self): + self.maxDiff = None + + self.compare_parse_result('tests/filepath/cobertura.xml', 'tests/filepath/coverage.json') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/coverage3/cobertura.xml b/tests/coverage3/cobertura.xml new file mode 100644 index 0000000..015adc2 --- /dev/null +++ b/tests/coverage3/cobertura.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/coverage3/coverage.json b/tests/coverage3/coverage.json new file mode 100644 index 0000000..b94ec68 --- /dev/null +++ b/tests/coverage3/coverage.json @@ -0,0 +1,32 @@ +{ + "total": 87, + "fileReports": [ + { + "total": 87, + "coverage": { + "5": 1, + "4": 1, + "6": 2 + }, + "filename": "src/test/resources/TestSourceFile.scala" + }, + { + "total": 87, + "coverage": { + "9": 1, + "10": 1 + }, + "filename": "src/test/resources/TestSourceFile.scala" + }, + { + "total": 87, + "coverage": { + "1": 1, + "3": 1, + "2": 1 + }, + "filename": "src/test/resources/TestSourceFile2.scala" + } + ], + "language": "python" +} diff --git a/tests/coverage4/cobertura.xml b/tests/coverage4/cobertura.xml new file mode 100644 index 0000000..0e71e5d --- /dev/null +++ b/tests/coverage4/cobertura.xml @@ -0,0 +1,86 @@ + + + + + + /Users/rafaelcortes/Documents/qamine/python-codacycov + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/coverage4/coverage.json b/tests/coverage4/coverage.json new file mode 100644 index 0000000..e0a780b --- /dev/null +++ b/tests/coverage4/coverage.json @@ -0,0 +1,49 @@ +{ + "total": 50.0, + "fileReports": [ + { + "total": 66.67, + "coverage": { + "1": 1, + "4": 1 + }, + "filename": "src/codacy/__init__.py" + }, + { + "total": 49.15, + "coverage": { + "50": 1, + "60": 1, + "80": 1, + "52": 1, + "26": 1, + "20": 1, + "49": 1, + "44": 1, + "42": 1, + "43": 1, + "3": 1, + "5": 1, + "4": 1, + "7": 1, + "6": 1, + "9": 1, + "11": 1, + "15": 1, + "14": 1, + "17": 1, + "16": 1, + "55": 1, + "54": 1, + "31": 1, + "30": 1, + "51": 1, + "36": 1, + "34": 1, + "57": 1 + }, + "filename": "src/codacy/reporter.py" + } + ], + "language": "python" +} \ No newline at end of file diff --git a/tests/filepath/cobertura.xml.tpl b/tests/filepath/cobertura.xml.tpl new file mode 100644 index 0000000..895abb8 --- /dev/null +++ b/tests/filepath/cobertura.xml.tpl @@ -0,0 +1,87 @@ + + + + + + $1/src/codacy + $1/src/codacy + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/filepath/coverage.json b/tests/filepath/coverage.json new file mode 100644 index 0000000..e0a780b --- /dev/null +++ b/tests/filepath/coverage.json @@ -0,0 +1,49 @@ +{ + "total": 50.0, + "fileReports": [ + { + "total": 66.67, + "coverage": { + "1": 1, + "4": 1 + }, + "filename": "src/codacy/__init__.py" + }, + { + "total": 49.15, + "coverage": { + "50": 1, + "60": 1, + "80": 1, + "52": 1, + "26": 1, + "20": 1, + "49": 1, + "44": 1, + "42": 1, + "43": 1, + "3": 1, + "5": 1, + "4": 1, + "7": 1, + "6": 1, + "9": 1, + "11": 1, + "15": 1, + "14": 1, + "17": 1, + "16": 1, + "55": 1, + "54": 1, + "31": 1, + "30": 1, + "51": 1, + "36": 1, + "34": 1, + "57": 1 + }, + "filename": "src/codacy/reporter.py" + } + ], + "language": "python" +} \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..7149960 --- /dev/null +++ b/tox.ini @@ -0,0 +1,6 @@ +[tox] +envlist = py27, py350 + +[testenv] +commands = make test +whitelist_externals=make diff --git a/version.py b/version.py new file mode 100644 index 0000000..b3ddbc4 --- /dev/null +++ b/version.py @@ -0,0 +1 @@ +__version__ = '1.1.1'