├── .codeclimate.yml ├── .dockerignore ├── .gitignore ├── .pypirc.sample ├── Dockerfile ├── LICENSE.txt ├── Makefile ├── README.md ├── bin ├── post-release ├── prep-release ├── release └── test-release ├── circle.yml ├── codeclimate_test_reporter ├── VERSION ├── __init__.py ├── __main__.py └── components │ ├── __init__.py │ ├── api_client.py │ ├── argument_parser.py │ ├── ci.py │ ├── file_coverage.py │ ├── formatter.py │ ├── git_command.py │ ├── payload_validator.py │ ├── reporter.py │ └── runner.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── fixtures │ ├── coverage.txt │ ├── coverage.xml │ ├── coverage_for_latin_1_source.xml │ ├── coverage_invalid_version.xml │ ├── latin_1_source.py │ └── source.py ├── test_api_client.py ├── test_argument_parser.py ├── test_ci.py ├── test_formatter.py ├── test_reporter.py ├── test_runner.py └── utils.py └── tox.ini /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - python 8 | exclude_paths: 9 | # duplicate structures in dictionary 10 | - codeclimate_test_reporter/components/ci.py 11 | fixme: 12 | enabled: true 13 | markdownlint: 14 | enabled: true 15 | pep8: 16 | enabled: true 17 | radon: 18 | enabled: true 19 | exclude_fingerprints: 20 | # complexity in PayloadValidator#validate 21 | - 1b18d0fde7d0511be196bcc366180bf8 22 | # complexity in Runner#run 23 | - c1a982077efa39ca50db944490e15485 24 | ratings: 25 | paths: 26 | - "**.inc" 27 | - "**.module" 28 | - "**.py" 29 | exclude_paths: 30 | - dist/ 31 | - tests/ 32 | - build/ 33 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/*.pyc 2 | .git 3 | .coverage 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # Installer logs 28 | pip-log.txt 29 | pip-delete-this-directory.txt 30 | 31 | # Unit test / coverage reports 32 | htmlcov/ 33 | .tox/ 34 | .coverage 35 | .coverage.* 36 | .cache 37 | nosetests.xml 38 | coverage.xml 39 | *,cover 40 | .hypothesis/ 41 | 42 | # dotenv 43 | .env 44 | -------------------------------------------------------------------------------- /.pypirc.sample: -------------------------------------------------------------------------------- 1 | [distutils] 2 | index-servers = 3 | pypi 4 | pypitest 5 | 6 | [pypi] 7 | repository=https://pypi.python.org/pypi 8 | username= 9 | password= 10 | 11 | [pypitest] 12 | repository=https://testpypi.python.org/pypi 13 | username= 14 | password= 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5.1-alpine 2 | 3 | WORKDIR /usr/src/app 4 | 5 | RUN apk --update add git 6 | 7 | COPY requirements.txt /usr/src/app/ 8 | RUN pip install --upgrade pip && \ 9 | pip install -r requirements.txt 10 | 11 | COPY . /usr/src/app 12 | 13 | RUN adduser -u 9000 -D app 14 | RUN chown -R app:app /usr/src/app 15 | USER app 16 | 17 | ENTRYPOINT ["/usr/local/bin/python", "-m", "codeclimate_test_reporter"] 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Code Climate LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | This gem includes code by Wil Gieseler, distributed under the MIT license: 22 | 23 | Permission is hereby granted, free of charge, to any person obtaining 24 | a copy of this software and associated documentation files (the 25 | "Software"), to deal in the Software without restriction, including 26 | without limitation the rights to use, copy, modify, merge, publish, 27 | distribute, sublicense, and/or sell copies of the Software, and to 28 | permit persons to whom the Software is furnished to do so, subject to 29 | the following conditions: 30 | 31 | The above copyright notice and this permission notice shall be 32 | included in all copies or substantial portions of the Software. 33 | 34 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 35 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 36 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 37 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 38 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 39 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 40 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 41 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all clean-pyc image test test-release release run 2 | 3 | IMAGE_NAME ?= codeclimate/python-test-reporter 4 | 5 | all: image 6 | 7 | clean-pyc: 8 | find . -name '*.pyc' -exec rm -f {} + 9 | find . -name '*.pyo' -exec rm -f {} + 10 | find . -name '*~' -exec rm -f {} + 11 | 12 | image: 13 | docker build --tag $(IMAGE_NAME) . 14 | 15 | test: image 16 | docker run \ 17 | -it \ 18 | --rm \ 19 | --entrypoint=/bin/sh \ 20 | $(IMAGE_NAME) -c 'python setup.py testcov' 21 | 22 | test-release: image 23 | docker run \ 24 | --rm \ 25 | --volume ~/.pypirc:/home/app/.pypirc \ 26 | --entrypoint=/bin/sh \ 27 | $(IMAGE_NAME) -c 'bin/test-release' 28 | 29 | release: image 30 | docker run \ 31 | --rm \ 32 | --volume ~/.pypirc:/home/app/.pypirc \ 33 | --entrypoint=/bin/sh \ 34 | $(IMAGE_NAME) -c 'bin/release' && bin/post-release 35 | 36 | run: image 37 | docker run --rm $(IMAGE_NAME) --version 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # codeclimate-test-reporter - [DEPRECATED] 2 | 3 | These configuration instructions refer to a language-specific test reporter which is now deprecated in favor of our new unified test reporter client. The [new test reporter](https://docs.codeclimate.com/v1.0/docs/configuring-test-coverage) is faster, distributed as a static binary, has support for parallelized CI builds, and will receive ongoing support by the team here. The existing test reporters for Ruby, Python, PHP, and Javascript are now deprecated. 4 | [![Code Climate][cc-badge]][cc-repo] 5 | [![Test Coverage][cc-coverage-badge]][cc-coverage] 6 | [![PyPI version][pypy-badge]][pypy] 7 | 8 | [cc-badge]: https://codeclimate.com/github/codeclimate/python-test-reporter/badges/gpa.svg 9 | [cc-coverage-badge]: https://codeclimate.com/github/codeclimate/python-test-reporter/badges/coverage.svg 10 | [cc-repo]: https://codeclimate.com/github/codeclimate/python-test-reporter 11 | [cc-coverage]: https://codeclimate.com/github/codeclimate/python-test-reporter/coverage 12 | [pypy-badge]: https://badge.fury.io/py/codeclimate-test-reporter.svg 13 | [pypy]: https://pypi.python.org/pypi/codeclimate-test-reporter 14 | 15 | Collects test coverage data from your Python test suite and sends it to Code 16 | Climate's hosted, automated code review service. 17 | 18 | Code Climate - [https://codeclimate.com][codeclimate.com] 19 | 20 | ## Uploading Your Test Coverage Report 21 | 22 | The `codeclimate-test-reporter` is compatible with [coverage.py][] coverage 23 | reports. By default, coverage.py will generate a `.coverage` file in the current 24 | directory. `codeclimate-test-reporter`, run without arguments, will 25 | look for a coverage report at this default location. 26 | 27 | [coverage.py]: https://coverage.readthedocs.org/ 28 | 29 | Note: The `codeclimate-test-reporter` requires a repo token from 30 | [codeclimate.com][], so if you don't have one, the first step is to [signup][] 31 | and configure your repo. Then: 32 | 33 | [codeclimate.com]: https://codeclimate.com 34 | [signup]: https://codeclimate.com/signup 35 | 36 | You can place the repo token in the environment under the key 37 | `CODECLIMATE_REPO_TOKEN` or pass the token as a CLI argument: 38 | 39 | ```console 40 | $ CODECLIMATE_REPO_TOKEN=[token] codeclimate-test-reporter 41 | Submitting payload to https://codeclimate.com... done! 42 | ``` 43 | 44 | ```console 45 | $ codeclimate-test-reporter --token [token] 46 | Submitting payload to https://codeclimate.com... done! 47 | ``` 48 | 49 | We recommend configuring the repo token in the environment through your CI 50 | settings which will hide the value during runs. The token should be considered a 51 | scoped password. Anyone with the token can submit test coverage data to your 52 | Code Climate repo. 53 | 54 | ```console 55 | # CODECLIMATE_REPO_TOKEN already set in env 56 | $ codeclimate-test-reporter 57 | Submitting payload to https://codeclimate.com... done! 58 | ``` 59 | 60 | ### Generating Coverage Reports 61 | 62 | To generate a coverage report with [pytest][], you can use the [pytest-cov][] 63 | plugin: 64 | 65 | ```console 66 | $ py.test --cov=your_package tests/ 67 | TOTAL 284 27 90% 68 | 69 | ======================== 14 passed in 0.75 seconds ======================== 70 | ``` 71 | 72 | To generate a coverage report with [nose][], you can use the [nose cover plugin][]: 73 | 74 | [pytest]: http://pytest.org 75 | [nose]: https://nose.readthedocs.org 76 | [pytest-cov]: https://pypi.python.org/pypi/pytest-cov 77 | [nose cover plugin]: https://nose.readthedocs.org/en/latest/plugins/cover.html 78 | 79 | ```console 80 | $ nosetests --with-coverage --cover-erase --cover-package=your_package 81 | TOTAL 284 27 90% 82 | ---------------------------------------------------------------------- 83 | Ran 14 tests in 0.743s 84 | 85 | OK 86 | ``` 87 | 88 | By default, coverage.py will create the test coverage report at `./.coverage`. 89 | If you configure coverage.py to generate a coverage report at an alternate 90 | location, pass that to the `codeclimate-test-reporter`: 91 | 92 | ```console 93 | $ codeclimate-test-reporter --file ./alternate/location/.coverage 94 | Submitting payload to https://codeclimate.com... done! 95 | ``` 96 | 97 | ## Installation 98 | 99 | You can install the codeclimate-test-reporter using pip: 100 | 101 | ```console 102 | $ pip install codeclimate-test-reporter 103 | Successfully installed codeclimate-test-reporter-[version] 104 | ``` 105 | 106 | Or you can add the reporter to your `requirements.txt` file and install it along 107 | with other project dependencies: 108 | 109 | ```txt 110 | # requirements.txt 111 | codeclimate-test-reporter 112 | ``` 113 | 114 | ```console 115 | $ pip install -r requirements.txt 116 | Successfully installed codeclimate-test-reporter-[version] 117 | ``` 118 | 119 | ## Important FYIs 120 | 121 | Across the many different testing frameworks, setups, and environments, there 122 | are lots of variables at play. Before setting up test coverage, it's important 123 | to understand what we do and do not currently support: 124 | 125 | * **Single payload:** We currently only support a single test coverage payload 126 | per commit. If you run your tests in multiple steps, or via parallel tests, 127 | Code Climate will only process the first payload that we receive. If you are 128 | using a CI, be sure to check if you are running your tests in a parallel mode. 129 | 130 | **Note:** If you've configured Code Climate to analyze multiple languages in 131 | the same repository (e.g., Python and JavaScript), we can nonetheless only 132 | process test coverage information for one of these languages. We'll process 133 | the first payload that we receive. 134 | 135 | * **Invalid File Paths:** By default, our test reporters expect your application 136 | to exist at the root of your repository. If this is not the case, the file 137 | paths in your test coverage payload will not match the file paths that Code 138 | Climate expects. 139 | 140 | ## Troubleshooting 141 | 142 | If you're having trouble setting up or working with our test coverage feature, 143 | see our detailed [help doc][], which covers the most common issues encountered. 144 | 145 | [help doc]: http://docs.codeclimate.com/article/220-help-im-having-trouble-with-test-coverage 146 | 147 | If the issue persists, feel free to [open up an issue][issue] here or contact 148 | help with debug information from the reporter: 149 | 150 | [issue]: https://github.com/codeclimate/python-test-reporter/issues/new 151 | 152 | ```console 153 | $ codeclimate-test-reporter --debug 154 | codeclimate-test-reporter [version] 155 | Requests 2.9.1 156 | Python 3.5.1 (default, Jan 22 2016, 08:54:32) 157 | [GCC 4.2.1 Compatible Apple LLVM 7.0.2 (clang-700.1.81)] 158 | /usr/local/opt/python3/bin/python3.5 159 | Darwin 15.4.0 160 | ``` 161 | 162 | ## Contributions 163 | 164 | Patches, bug fixes, feature requests, and pull requests are welcome. 165 | 166 | ## Development 167 | 168 | A `Dockerfile` is included for developer convenience. Simply run `make test` to 169 | run the test suite and coverage report. 170 | 171 | To release a new version, first run `./bin/prep-release [version]` to bump the 172 | version and open a PR on GitHub. 173 | 174 | Once the PR is merged, run `make release` to publish the new version to pypy and 175 | create a tag on GitHub. This step requires pypy credentials at `~/.pypirc`. You 176 | can copy this repo's `.pypirc.sample` file as a starting point. 177 | 178 | ## Copyright 179 | 180 | See [LICENSE.txt][license] 181 | 182 | [license]: https://github.com/codeclimate/python-test-reporter/blob/master/LICENSE.txt 183 | -------------------------------------------------------------------------------- /bin/post-release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Usage: bin/post-release 4 | # 5 | ### 6 | set -e 7 | 8 | git tag -f v$(cat codeclimate_test_reporter/VERSION) 9 | git push origin --tags 10 | -------------------------------------------------------------------------------- /bin/prep-release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Open a PR for releasing a new version of this repository. 4 | # 5 | # Usage: bin/prep-release VERSION 6 | # 7 | ### 8 | set -e 9 | 10 | if [ -z "$1" ]; then 11 | echo "usage: bin/prep-release VERSION" >&2 12 | exit 64 13 | fi 14 | 15 | version=$1 16 | old_version=$(< codeclimate_test_reporter/VERSION) 17 | branch="release-$version" 18 | 19 | if ! make test; then 20 | echo "test failure, not releasing" >&2 21 | exit 1 22 | fi 23 | 24 | printf "RELEASE %s => %s\n" "$old_version" "$version" 25 | git checkout master 26 | git pull origin master 27 | 28 | git checkout -b "$branch" 29 | 30 | printf "%s\n" "$version" > codeclimate_test_reporter/VERSION 31 | git add codeclimate_test_reporter/VERSION 32 | git commit -m "Release v$version" 33 | git push origin "$branch" 34 | 35 | branch_head=$(git rev-parse --short $branch) 36 | if command -v hub > /dev/null 2>&1; then 37 | hub pull-request -F - <&2 44 | fi 45 | 46 | echo "After merging the version-bump PR, run: make release" 47 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Release a new version of this repository. 4 | # 5 | # Assumes bin/prep-release was run and the PR merged. 6 | # 7 | # Usage: bin/release 8 | # 9 | ### 10 | set -e 11 | 12 | python setup.py build 13 | python setup.py register -r pypi 14 | python setup.py sdist upload -r pypi 15 | -------------------------------------------------------------------------------- /bin/test-release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Test release a new version of this repository. 4 | # 5 | # Usage: bin/test-release 6 | # 7 | ### 8 | 9 | python setup.py build 10 | python setup.py register -r pypitest 11 | python setup.py sdist upload -r pypitest 12 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | environment: 3 | CODECLIMATE_REPO_TOKEN: 783d26fa43e7bee1cc8791677e808941795bc20f6d530e92905e2d8577a2de06 4 | 5 | dependencies: 6 | override: 7 | - pip install tox tox-pyenv 8 | - pyenv local 2.7.11 3.4.4 3.5.1 9 | 10 | test: 11 | override: 12 | - tox 13 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/VERSION: -------------------------------------------------------------------------------- 1 | 0.2.3 2 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | codeclimate-test-reporter 3 | """ 4 | 5 | import os 6 | 7 | __author__ = "Code Climate" 8 | __version__ = open(os.path.join(os.path.dirname(__file__), "VERSION")).read().strip() 9 | __license__ = "MIT" 10 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | 5 | from .components.runner import Runner 6 | 7 | 8 | def run(): 9 | runner = Runner() 10 | 11 | sys.exit(runner.run()) 12 | 13 | if __name__ == '__main__': 14 | run() 15 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/components/__init__.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/components/api_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import requests 4 | 5 | 6 | class ApiClient: 7 | def __init__(self, host=None, timeout=5): 8 | self.host = host or self.__default_host().rstrip("/") 9 | self.timeout = timeout 10 | 11 | def post(self, payload): 12 | headers = {"Content-Type": "application/json"} 13 | response = requests.post( 14 | "%s/test_reports" % self.host, 15 | data=json.dumps(payload), 16 | headers=headers, 17 | timeout=self.timeout 18 | ) 19 | 20 | return response 21 | 22 | def __default_host(self): 23 | return os.environ.get("CODECLIMATE_API_HOST", "https://codeclimate.com") 24 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/components/argument_parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | CLI arguments definition. 3 | """ 4 | 5 | import argparse 6 | import sys 7 | 8 | 9 | class ArgumentParser(argparse.ArgumentParser): 10 | def __init__(self): 11 | argparse.ArgumentParser.__init__( 12 | self, 13 | prog="codeclimate-test-reporter", 14 | description="Report test coverage to Code Climate" 15 | ) 16 | 17 | self.add_argument( 18 | "--file", 19 | help="A coverage.py coverage file to report", 20 | default="./.coverage" 21 | ) 22 | 23 | self.add_argument("--token", help="Code Climate repo token") 24 | self.add_argument("--stdout", help="Output to STDOUT", action="store_true") 25 | self.add_argument("--debug", help="Enable debug mode", action="store_true") 26 | self.add_argument("--version", help="Show the version", action="store_true") 27 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/components/ci.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import itertools 5 | 6 | 7 | class CI: 8 | def __init__(self, env=os.environ): 9 | self.env = env 10 | 11 | def predicate(self, service): 12 | return service["matcher"](self.env) 13 | 14 | def data(self): 15 | service = next(iter(filter(self.predicate, self.__services())), None) 16 | 17 | if service: 18 | return service["data"](self.env) 19 | else: 20 | return {} 21 | 22 | def __services(self): 23 | return [{ 24 | "matcher": lambda env: env.get("TRAVIS"), 25 | "data": lambda env: { 26 | "name": "travis-ci", 27 | "branch": self.env.get("TRAVIS_BRANCH"), 28 | "build_identifier": self.env.get("TRAVIS_JOB_ID"), 29 | "pull_request": self.env.get("TRAVIS_PULL_REQUEST") 30 | } 31 | }, { 32 | "matcher": lambda env: env.get("CIRCLECI"), 33 | "data": lambda env: { 34 | "name": "circleci", 35 | "branch": self.env.get("CIRCLE_BRANCH"), 36 | "build_identifier": self.env.get("CIRCLE_BUILD_NUM"), 37 | "commit_sha": self.env.get("CIRCLE_SHA1") 38 | } 39 | }, { 40 | "matcher": lambda env: env.get("SEMAPHORE"), 41 | "data": lambda env: { 42 | "name": "semaphore", 43 | "branch": self.env.get("BRANCH_NAME"), 44 | "build_identifier": self.env.get("SEMAPHORE_BUILD_NUMBER") 45 | } 46 | }, { 47 | "matcher": lambda env: env.get("JENKINS_URL"), 48 | "data": lambda env: { 49 | "name": "jenkins", 50 | "build_identifier": self.env.get("BUILD_NUMBER"), 51 | "build_url": self.env.get("BUILD_URL"), 52 | "branch": self.env.get("GIT_BRANCH"), 53 | "commit_sha": self.env.get("GIT_COMMIT") 54 | } 55 | }, { 56 | "matcher": lambda env: env.get("TDDIUM"), 57 | "data": lambda env: { 58 | "name": "tddium", 59 | "build_identifier": self.env.get("TDDIUM_SESSION_ID"), 60 | "worker_id": self.env.get("TDDIUM_TID") 61 | } 62 | }, { 63 | "matcher": lambda env: env.get("WERCKER"), 64 | "data": lambda env: { 65 | "name": "wercker", 66 | "build_identifier": self.env.get("WERCKER_BUILD_ID"), 67 | "build_url": self.env.get("WERCKER_BUILD_URL"), 68 | "branch": self.env.get("WERCKER_GIT_BRANCH"), 69 | "commit_sha": self.env.get("WERCKER_GIT_COMMIT") 70 | } 71 | }, { 72 | "matcher": lambda env: env.get("APPVEYOR"), 73 | "data": lambda env: { 74 | "name": "appveyor", 75 | "build_identifier": self.env.get("APPVEYOR_BUILD_ID"), 76 | "build_url": self.env.get("APPVEYOR_API_URL"), 77 | "branch": self.env.get("APPVEYOR_REPO_BRANCH"), 78 | "commit_sha": self.env.get("APPVEYOR_REPO_COMMIT"), 79 | "pull_request": self.env.get("APPVEYOR_PULL_REQUEST_NUMBER") 80 | } 81 | }, { 82 | "matcher": lambda env: self.__ci_name_match("DRONE"), 83 | "data": lambda env: { 84 | "name": "drone", 85 | "build_identifier": self.env.get("CI_BUILD_NUMBER"), 86 | "build_url": self.env.get("CI_BUILD_URL"), 87 | "branch": self.env.get("CI_BRANCH"), 88 | "commit_sha": self.env.get("CI_COMMIT"), 89 | "pull_request": self.env.get("CI_PULL_REQUEST") 90 | } 91 | }, { 92 | "matcher": lambda env: self.__ci_name_match("CODESHIP"), 93 | "data": lambda env: { 94 | "name": "codeship", 95 | "build_identifier": self.env.get("CI_BUILD_NUMBER"), 96 | "build_url": self.env.get("CI_BUILD_URL"), 97 | "branch": self.env.get("CI_BRANCH"), 98 | "commit_sha": self.env.get("CI_COMMIT_ID") 99 | } 100 | }, { 101 | "matcher": lambda env: self.__ci_name_match("VEXOR"), 102 | "data": lambda env: { 103 | "name": "vexor", 104 | "build_identifier": self.env.get("CI_BUILD_NUMBER"), 105 | "build_url": self.env.get("CI_BUILD_URL"), 106 | "branch": self.env.get("CI_BRANCH"), 107 | "commit_sha": self.env.get("CI_BUILD_SHA"), 108 | "pull_request": self.env.get("CI_PULL_REQUEST_ID") 109 | } 110 | }, { 111 | "matcher": lambda env: env.get("BUILDKITE"), 112 | "data": lambda env: { 113 | "name": "buildkite", 114 | "build_identifier": self.env.get("BUILDKITE_JOB_ID"), 115 | "build_url": self.env.get("BUILDKITE_BUILD_URL"), 116 | "branch": self.env.get("BUILDKITE_BRANCH"), 117 | "commit_sha": self.env.get("BUILDKITE_COMMIT"), 118 | } 119 | }, { 120 | "matcher": lambda env: env.get("GITLAB_CI"), 121 | "data": lambda env: { 122 | "name": "gitlab-ci", 123 | "build_identifier": self.env.get("CI_BUILD_ID"), 124 | "branch": self.env.get("CI_BUILD_REF_NAME"), 125 | "commit_sha": self.env.get("CI_BUILD_REF"), 126 | } 127 | }] 128 | 129 | def __ci_name_match(self, pattern): 130 | ci_name = self.env.get("CI_NAME") 131 | 132 | return ci_name and re.match(pattern, ci_name, re.IGNORECASE) 133 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/components/file_coverage.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from hashlib import sha1 4 | 5 | if sys.version_info < (3, 0): 6 | from io import open 7 | 8 | 9 | def read_file_content(path): 10 | """ 11 | Read a file content, either if it is utf-8 or latin-1 encoding 12 | :param path: path to the file 13 | :return: file content 14 | """ 15 | try: 16 | return open(path, "r", encoding="utf-8-sig").read() 17 | except UnicodeDecodeError: 18 | return open(path, "r", encoding="iso-8859-1").read() 19 | 20 | 21 | class FileCoverage: 22 | def __init__(self, file_node): 23 | self.file_body = None 24 | self.file_node = file_node 25 | self.__process() 26 | 27 | def payload(self): 28 | return { 29 | "name": self.__filename(), 30 | "blob_id": self.__blob(), 31 | "covered_strength": self.__covered_strength(), 32 | "covered_percent": self.__covered_percent(), 33 | "coverage": json.dumps(self.__coverage()), 34 | "line_counts": self.__line_counts() 35 | } 36 | 37 | def __process(self): 38 | self.total = len(self.__line_nodes()) 39 | self.hits = 0 40 | self.covered = 0 41 | self.missed = 0 42 | 43 | for line_node in self.__line_nodes(): 44 | hits = int(line_node.get("hits")) 45 | 46 | if hits > 0: 47 | self.covered += 1 48 | self.hits += hits 49 | else: 50 | self.missed += 1 51 | 52 | def __line_nodes(self): 53 | return self.file_node.findall("lines/line") 54 | 55 | def __blob(self): 56 | contents = self.__file_body() 57 | header = "blob " + str(len(contents)) + "\0" 58 | 59 | return sha1((header + contents).encode("utf-8")).hexdigest() 60 | 61 | def __file_body(self): 62 | if not self.file_body: 63 | self.file_body = read_file_content(self.__filename()) 64 | 65 | return self.file_body 66 | 67 | def __filename(self): 68 | return self.file_node.get("filename") 69 | 70 | def __rate(self): 71 | return self.file_node.get("line-rate") 72 | 73 | def __covered_strength(self): 74 | return self.__guard_division(self.hits, self.covered) 75 | 76 | def __num_lines(self): 77 | return len(self.__file_body().splitlines()) 78 | 79 | def __covered_percent(self): 80 | return self.__guard_division(self.covered, self.total) 81 | 82 | def __guard_division(self, dividend, divisor): 83 | if (divisor > 0): 84 | return dividend / float(divisor) 85 | else: 86 | return 0.0 87 | 88 | def __coverage(self): 89 | coverage = [None] * self.__num_lines() 90 | 91 | for line_node in self.__line_nodes(): 92 | index = int(line_node.get("number")) - 1 93 | hits = int(line_node.get("hits")) 94 | 95 | coverage[index] = hits 96 | 97 | return coverage 98 | 99 | def __line_counts(self): 100 | return { 101 | "total": self.total, 102 | "covered": self.covered, 103 | "missed": self.missed 104 | } 105 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/components/formatter.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as ET 2 | 3 | from ..__init__ import __version__ as reporter_version 4 | from .ci import CI 5 | from .file_coverage import FileCoverage 6 | from .git_command import GitCommand 7 | 8 | 9 | class InvalidReportVersion(Exception): 10 | pass 11 | 12 | 13 | class Formatter: 14 | def __init__(self, xml_report_path, debug=False): 15 | self.debug = debug 16 | tree = ET.parse(xml_report_path) 17 | self.root = tree.getroot() 18 | 19 | def payload(self): 20 | self.__validate_report_version() 21 | 22 | total_line_counts = {"covered": 0, "missed": 0, "total": 0} 23 | total_covered_strength = 0.0 24 | total_covered_percent = 0.0 25 | 26 | source_files = self.__source_files() 27 | 28 | for source_file in source_files: 29 | total_covered_strength += source_file["covered_strength"] 30 | total_covered_percent += source_file["covered_percent"] 31 | 32 | for key, value in source_file["line_counts"].items(): 33 | total_line_counts[key] += value 34 | 35 | total_covered_strength = round(total_covered_strength / len(source_files), 2) 36 | total_covered_percent = round(total_covered_percent / len(source_files), 2) 37 | 38 | return { 39 | "run_at": self.__timestamp(), 40 | "covered_percent": total_covered_percent, 41 | "covered_strength": total_covered_strength, 42 | "line_counts": total_line_counts, 43 | "partial": False, 44 | "git": self.__git_info(), 45 | "environment": { 46 | "pwd": self.root.find("sources").find("source").text, 47 | "reporter_version": reporter_version 48 | }, 49 | "ci_service": self.__ci_data(), 50 | "source_files": source_files 51 | } 52 | 53 | def __validate_report_version(self): 54 | report_version = self.root.get("version") 55 | 56 | if report_version < "4.0" or report_version >= "4.4": 57 | message = """ 58 | This reporter is compatible with Coverage.py versions >=4.0,<4.4. 59 | Your Coverage.py report version is %s. 60 | 61 | Consider locking your version of Coverage.py to >4,0,<4.4 until the 62 | following Coverage.py issue is addressed: 63 | https://bitbucket.org/ned/coveragepy/issues/578/incomplete-file-path-in-xml-report 64 | """ % (report_version) 65 | 66 | raise InvalidReportVersion(message) 67 | 68 | def __ci_data(self): 69 | return CI().data() 70 | 71 | def __source_files(self): 72 | source_files = [] 73 | 74 | for file_node in self.__file_nodes(): 75 | if self.debug: 76 | print("Processing file path=%s" % file_node.get("filename")) 77 | 78 | file_coverage = FileCoverage(file_node) 79 | payload = file_coverage.payload() 80 | source_files.append(payload) 81 | 82 | return source_files 83 | 84 | def __file_nodes(self): 85 | return self.root.findall("packages/package/classes/class") 86 | 87 | def __timestamp(self): 88 | return self.root.get("timestamp") 89 | 90 | def __git_info(self): 91 | ci_data = self.__ci_data() 92 | command = GitCommand() 93 | 94 | return { 95 | "branch": ci_data.get("branch") or command.branch(), 96 | "committed_at": command.committed_at(), 97 | "head": ci_data.get("commit_sha") or command.head() 98 | } 99 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/components/git_command.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | 5 | class GitCommand: 6 | def branch(self): 7 | return self.__execute("git rev-parse --abbrev-ref HEAD") 8 | 9 | def committed_at(self): 10 | return self.__execute("git log -1 --pretty=format:%ct") 11 | 12 | def head(self): 13 | return self.__execute("git log -1 --pretty=format:'%H'") 14 | 15 | def __execute(self, command): 16 | process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE) 17 | exit_code = process.wait() 18 | 19 | if exit_code == 0: 20 | return process.stdout.read().decode("utf-8").strip() 21 | else: 22 | return None 23 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/components/payload_validator.py: -------------------------------------------------------------------------------- 1 | class InvalidPayload(Exception): 2 | pass 3 | 4 | 5 | class PayloadValidator: 6 | def __init__(self, payload): 7 | self.payload = payload 8 | 9 | def validate(self): 10 | if not self.__commit_sha(): 11 | raise InvalidPayload("A git commit sha was not found in the test report payload") 12 | elif not self.__committed_at(): 13 | raise InvalidPayload("A git commit timestamp was not found in the test report payload") 14 | elif not self.__run_at(): 15 | raise InvalidPayload("A run at timestamp was not found in the test report payload") 16 | elif not self.__source_files(): 17 | raise InvalidPayload("No source files were found in the test report payload") 18 | elif not self.__valid_source_files(): 19 | raise InvalidPayload("Invalid source files were found in the test report payload") 20 | else: 21 | return True 22 | 23 | def __commit_sha(self): 24 | return self.__commit_sha_from_git() or self.__commit_sha_from_ci_service() 25 | 26 | def __commit_sha_from_git(self): 27 | return self.__validate_payload_value(["git", "head"]) 28 | 29 | def __commit_sha_from_ci_service(self): 30 | return self.__validate_payload_value(["ci_service", "commit_sha"]) 31 | 32 | def __committed_at(self): 33 | return self.__validate_payload_value(["git", "committed_at"]) 34 | 35 | def __run_at(self): 36 | return self.payload.get("run_at") 37 | 38 | def __source_files(self): 39 | return self.payload.get("source_files") 40 | 41 | def __validate_payload_value(self, keys): 42 | current = self.payload 43 | 44 | for key in keys: 45 | next = current.get(key) 46 | 47 | if next: 48 | current = next 49 | else: 50 | return False 51 | 52 | return True 53 | 54 | def __valid_source_files(self): 55 | return all(self.__valid_source_file(source_file) for source_file in self.__source_files()) 56 | 57 | def __valid_source_file(self, source_file): 58 | return type(source_file) is dict and source_file.get("name") and source_file.get("coverage") 59 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/components/reporter.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import coverage as Coverage 4 | import json 5 | import os 6 | import sys 7 | 8 | from ..components.api_client import ApiClient 9 | from ..components.formatter import Formatter 10 | from ..components.payload_validator import PayloadValidator 11 | 12 | 13 | class CoverageFileNotFound(Exception): 14 | pass 15 | 16 | 17 | class Reporter: 18 | def __init__(self, args): 19 | self.args = args 20 | 21 | def run(self): 22 | """ 23 | The main program without error handling 24 | 25 | :param args: parsed args (argparse.Namespace) 26 | :return: status code 27 | 28 | """ 29 | 30 | if not os.path.isfile(self.args.file): 31 | message = "Coverage file `" + self.args.file + "` file not found. " 32 | raise CoverageFileNotFound(message) 33 | 34 | xml_filepath = self.__create_xml_report(self.args.file) 35 | formatter = Formatter(xml_filepath, self.args.debug) 36 | payload = formatter.payload() 37 | 38 | PayloadValidator(payload).validate() 39 | 40 | if self.args.stdout: 41 | print(json.dumps(payload)) 42 | 43 | return 0 44 | else: 45 | client = ApiClient() 46 | 47 | print("Submitting payload to %s... " % client.host, end="") 48 | sys.stdout.flush() 49 | 50 | response = self.__post_payload(client, payload) 51 | 52 | print("done!") 53 | 54 | return response 55 | 56 | def __post_payload(self, client, payload): 57 | payload["repo_token"] = self.args.token or os.environ.get("CODECLIMATE_REPO_TOKEN") 58 | 59 | if payload["repo_token"]: 60 | response = client.post(payload) 61 | response.raise_for_status() 62 | 63 | return 0 64 | else: 65 | print("CODECLIMATE_REPO_TOKEN is required and not set") 66 | 67 | return 1 68 | 69 | def __create_xml_report(self, file): 70 | cov = Coverage.coverage(file) 71 | cov.load() 72 | data = cov.get_data() 73 | 74 | xml_filepath = "/tmp/coverage.xml" 75 | cov.xml_report(outfile=xml_filepath) 76 | 77 | return xml_filepath 78 | -------------------------------------------------------------------------------- /codeclimate_test_reporter/components/runner.py: -------------------------------------------------------------------------------- 1 | """This module provides the main functionality of codeclimate-test-reporter. 2 | 3 | Invocation flow: 4 | 5 | 1. Read, validate and process the input (args, `stdin`). 6 | 2. Create and send test coverage to codeclimate.com. 7 | 3. Report back to `stdout`. 8 | 4. Exit. 9 | 10 | """ 11 | from coverage.misc import CoverageException 12 | import os 13 | import platform 14 | import sys 15 | import requests.exceptions 16 | 17 | from ..__init__ import __version__ as reporter_version 18 | from ..components.argument_parser import ArgumentParser 19 | from ..components.formatter import InvalidReportVersion 20 | from ..components.payload_validator import InvalidPayload 21 | from ..components.reporter import CoverageFileNotFound, Reporter 22 | 23 | 24 | class Runner: 25 | def __init__(self, args=sys.argv[1:], out=sys.stdout, err=sys.stderr): 26 | self.parsed_args = ArgumentParser().parse_args(args) 27 | self.out = out 28 | self.err = err 29 | 30 | def run(self): 31 | """ 32 | The main function. 33 | 34 | Pre-process args, handle some special types of invocations, 35 | and run the main program with error handling. 36 | 37 | Return exit status code. 38 | 39 | """ 40 | 41 | if self.parsed_args.version: 42 | self.out.write(reporter_version) 43 | return 0 44 | 45 | if self.parsed_args.debug: 46 | self.out.write(self.__debug_info()) 47 | 48 | try: 49 | reporter = Reporter(self.parsed_args) 50 | exit_status = reporter.run() 51 | return exit_status 52 | except CoverageFileNotFound as e: 53 | return self.__handle_error( 54 | str(e) + "\nUse --file to specifiy an alternate location." 55 | ) 56 | except CoverageException as e: 57 | return self.__handle_error(str(e), support=True) 58 | except InvalidPayload as e: 59 | return self.__handle_error("Invalid Payload: " + str(e), support=True) 60 | except InvalidReportVersion as e: 61 | return self.__handle_error(str(e)) 62 | except requests.exceptions.HTTPError as e: 63 | return self.__handle_error(str(e), support=True) 64 | except requests.exceptions.Timeout: 65 | return self.__handle_error( 66 | "Client HTTP Timeout: No response in 5 seconds.", 67 | support=True 68 | ) 69 | 70 | def __handle_error(self, message, support=False): 71 | self.err.write(message) 72 | 73 | if support: 74 | self.err.write( 75 | "\n\nContact support at https://codeclimate.com/help " 76 | "with the following debug info if error persists:" 77 | "\n" + message + "\n" + self.__debug_info() 78 | ) 79 | 80 | return 1 81 | 82 | def __debug_info(self): 83 | from requests import __version__ as requests_version 84 | 85 | return "\n".join([ 86 | "codeclimate-test-repoter %s" % reporter_version, 87 | "Requests %s" % requests_version, 88 | "Python %s\n%s" % (sys.version, sys.executable), 89 | "%s %s" % (platform.system(), platform.release()), 90 | ]) 91 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | coverage>=4.0,<4.4 3 | pytest-cov 4 | pytest 5 | HTTPretty 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [pep8] 5 | max-line-length = 100 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import Command, find_packages, setup 2 | from subprocess import call 3 | 4 | from codeclimate_test_reporter.__init__ import __version__ as reporter_version 5 | 6 | 7 | class RunTests(Command): 8 | description = "Run tests" 9 | user_options = [] 10 | 11 | def initialize_options(self): 12 | pass 13 | 14 | def finalize_options(self): 15 | pass 16 | 17 | def run(self): 18 | errno = call(["py.test", "tests/"]) 19 | raise SystemExit(errno) 20 | 21 | 22 | class RunTestsCov(RunTests): 23 | description = "Run tests w/ coverage" 24 | 25 | def run(self): 26 | """Run all tests with coverage!""" 27 | errno = call(["py.test", "--cov=codeclimate_test_reporter", "tests/"]) 28 | raise SystemExit(errno) 29 | 30 | 31 | setup( 32 | name="codeclimate-test-reporter", 33 | version=reporter_version, 34 | description="Report test coverage to Code Climate", 35 | url="http://github.com/codeclimate/python-test-reporter", 36 | author="Code Climate", 37 | author_email="hello@codeclimate.com", 38 | maintainer="Code Climate", 39 | maintainer_email="hello@codeclimate.com", 40 | license="MIT", 41 | packages=find_packages(exclude=["tests"]), 42 | zip_safe=False, 43 | cmdclass={"test": RunTests, "testcov": RunTestsCov}, 44 | entry_points={ 45 | "console_scripts": [ 46 | "codeclimate-test-reporter=codeclimate_test_reporter.__main__:run", 47 | ], 48 | }, 49 | package_data={"codeclimate_test_reporter": ["VERSION"]}, 50 | install_requires=["coverage>=4.0", "requests"], 51 | ) 52 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | -------------------------------------------------------------------------------- /tests/fixtures/coverage.txt: -------------------------------------------------------------------------------- 1 | !coverage.py: This is a private format, don't read it directly!{"lines": {"source.py": [4, 5, 6, 7, 9, 10, 12, 15, 16]}} 2 | -------------------------------------------------------------------------------- /tests/fixtures/coverage.xml: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /tests/fixtures/coverage_for_latin_1_source.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | /home/ulysse/Documents/programmes/python-test-reporter/tests/fixtures 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/fixtures/coverage_invalid_version.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/fixtures/latin_1_source.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeclimate/python-test-reporter/ecd4153eff3b0a4d54e1afb08d9416409897bc13/tests/fixtures/latin_1_source.py -------------------------------------------------------------------------------- /tests/fixtures/source.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | 4 | class Person: 5 | def __init__(self, first_name, last_name): 6 | self.first_name = first_name 7 | self.last_name = last_name 8 | 9 | def fullname(self): 10 | return "%s %s" % (self.first_name, self.last_name) 11 | 12 | def not_called(self): 13 | print("Shouldn't be called") 14 | 15 | person = Person("Marty", "McFly") 16 | person.fullname() 17 | -------------------------------------------------------------------------------- /tests/test_api_client.py: -------------------------------------------------------------------------------- 1 | import httpretty 2 | import os 3 | import pytest 4 | 5 | from codeclimate_test_reporter.components.api_client import ApiClient 6 | 7 | 8 | def test_post(): 9 | httpretty.enable() 10 | httpretty.register_uri( 11 | httpretty.POST, 12 | "http://example.com/test_reports", 13 | status=200, 14 | body="ok", 15 | content_type="application/plain" 16 | ) 17 | 18 | client = ApiClient(host="http://example.com") 19 | response = client.post({}) 20 | 21 | assert(response.status_code == 200) 22 | 23 | httpretty.disable() 24 | httpretty.reset() 25 | 26 | def test_env_host(): 27 | os.environ["CODECLIMATE_API_HOST"] = "http://example.com" 28 | 29 | client = ApiClient() 30 | 31 | assert(client.host == "http://example.com") 32 | 33 | del os.environ["CODECLIMATE_API_HOST"] 34 | 35 | def test_default_host(): 36 | client = ApiClient() 37 | 38 | assert(client.host == "https://codeclimate.com") 39 | -------------------------------------------------------------------------------- /tests/test_argument_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from codeclimate_test_reporter.components.argument_parser import ArgumentParser 4 | 5 | 6 | def test_parse_args_default(): 7 | parsed_args = ArgumentParser().parse_args([]) 8 | 9 | assert(parsed_args.file == "./.coverage") 10 | assert(parsed_args.token is None) 11 | assert(parsed_args.stdout is False) 12 | assert(parsed_args.debug is False) 13 | assert(parsed_args.version is False) 14 | 15 | def test_parse_args_with_options(): 16 | args = ["--version", "--debug", "--stdout", "--file", "file", "--token", "token"] 17 | parsed_args = ArgumentParser().parse_args(args) 18 | 19 | assert(parsed_args.debug) 20 | assert(parsed_args.file == "file") 21 | assert(parsed_args.token == "token") 22 | assert(parsed_args.stdout) 23 | assert(parsed_args.version) 24 | -------------------------------------------------------------------------------- /tests/test_ci.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from codeclimate_test_reporter.components.ci import CI 4 | 5 | 6 | def test_travis_data(): 7 | env = { 8 | "TRAVIS": True, 9 | "TRAVIS_BRANCH": "master", 10 | "TRAVIS_JOB_ID": "4.1", 11 | "TRAVIS_PULL_REQUEST": "false" 12 | } 13 | 14 | expected_data = { 15 | "name": "travis-ci", 16 | "branch": env["TRAVIS_BRANCH"], 17 | "build_identifier": env["TRAVIS_JOB_ID"], 18 | "pull_request": env["TRAVIS_PULL_REQUEST"] 19 | } 20 | 21 | data = CI(env).data() 22 | 23 | assert data == expected_data 24 | 25 | def test_circle_data(): 26 | env = { 27 | "CIRCLECI": True, 28 | "CIRCLE_BRANCH": "master", 29 | "CIRCLE_BUILD_NUM": "123", 30 | "CIRCLE_SHA1": "7638417db6d59f3c431d3e1f261cc637155684cd" 31 | } 32 | 33 | expected_data = { 34 | "name": "circleci", 35 | "branch": env["CIRCLE_BRANCH"], 36 | "build_identifier": env["CIRCLE_BUILD_NUM"], 37 | "commit_sha": env["CIRCLE_SHA1"] 38 | } 39 | 40 | data = CI(env).data() 41 | 42 | assert data == expected_data 43 | 44 | def test_ci_pick(): 45 | assert CI({ "TRAVIS": True }).data()["name"] == "travis-ci" 46 | assert CI({ "CIRCLECI": True }).data()["name"] == "circleci" 47 | assert CI({ "SEMAPHORE": True }).data()["name"] == "semaphore" 48 | assert CI({ "JENKINS_URL": True }).data()["name"] == "jenkins" 49 | assert CI({ "TDDIUM": True }).data()["name"] == "tddium" 50 | assert CI({ "WERCKER": True }).data()["name"] == "wercker" 51 | assert CI({ "APPVEYOR": True }).data()["name"] == "appveyor" 52 | assert CI({ "CI_NAME": "DRONE" }).data()["name"] == "drone" 53 | assert CI({ "CI_NAME": "CODESHIP" }).data()["name"] == "codeship" 54 | assert CI({ "CI_NAME": "VEXOR" }).data()["name"] == "vexor" 55 | assert CI({ "BUILDKITE": True }).data()["name"] == "buildkite" 56 | assert CI({ "GITLAB_CI": True }).data()["name"] == "gitlab-ci" 57 | -------------------------------------------------------------------------------- /tests/test_formatter.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import shutil 4 | import subprocess 5 | import unittest 6 | 7 | from codeclimate_test_reporter.components.formatter import Formatter 8 | from codeclimate_test_reporter.components.formatter import InvalidReportVersion 9 | from codeclimate_test_reporter.components.payload_validator import PayloadValidator 10 | 11 | 12 | class FormatterTest(unittest.TestCase): 13 | def test_payload_latin_1(self): 14 | orig_dir = os.getcwd() 15 | os.chdir("./tests/fixtures") 16 | 17 | self.__setup_git() 18 | 19 | try: 20 | formatter = Formatter("coverage_for_latin_1_source.xml") 21 | payload = formatter.payload() 22 | finally: 23 | os.chdir(orig_dir) 24 | shutil.rmtree("./tests/fixtures/.git") 25 | 26 | source_file = payload["source_files"][0] 27 | expected_line_counts = {"covered": 9, "missed": 1, "total": 10} 28 | expected_coverage = "[null, null, null, null, 1, 1, 1, 1, null, 1, 1, null, 1, 0, null, 1, 1]" 29 | 30 | assert type(payload) is dict 31 | assert len(payload["source_files"]) == 1 32 | 33 | assert source_file["line_counts"] == expected_line_counts 34 | assert source_file["covered_percent"] == 0.9 35 | assert source_file["covered_strength"] == 1.0 36 | assert source_file["coverage"] == expected_coverage 37 | 38 | assert PayloadValidator(payload).validate() 39 | 40 | def test_payload(self): 41 | orig_dir = os.getcwd() 42 | os.chdir("./tests/fixtures") 43 | 44 | self.__setup_git() 45 | 46 | try: 47 | formatter = Formatter("coverage.xml") 48 | payload = formatter.payload() 49 | finally: 50 | os.chdir(orig_dir) 51 | shutil.rmtree("./tests/fixtures/.git") 52 | 53 | assert type(payload) is dict 54 | assert len(payload["source_files"]) == 1 55 | 56 | source_file = payload["source_files"][0] 57 | expected_line_counts = {"covered": 9, "missed": 1, "total": 10} 58 | expected_coverage = "[null, null, null, 1, 1, 1, 1, null, 1, 1, null, 1, 0, null, 1, 1]" 59 | 60 | assert source_file["line_counts"] == expected_line_counts 61 | assert source_file["covered_percent"] == 0.9 62 | assert source_file["covered_strength"] == 1.0 63 | assert source_file["coverage"] == expected_coverage 64 | 65 | assert PayloadValidator(payload).validate() 66 | 67 | def test_payload_incompatible_version(self): 68 | orig_dir = os.getcwd() 69 | os.chdir("./tests/fixtures") 70 | 71 | try: 72 | formatter = Formatter("coverage_invalid_version.xml") 73 | self.assertRaises(InvalidReportVersion, formatter.payload) 74 | finally: 75 | os.chdir(orig_dir) 76 | 77 | def __setup_git(self): 78 | subprocess.call(["git", "init"]) 79 | subprocess.call(["git", "config", "user.name", "Test User"]) 80 | subprocess.call(["git", "config", "user.email", "test@example.com"]) 81 | subprocess.call(["git", "commit", "--allow-empty", "--message", "init"]) 82 | -------------------------------------------------------------------------------- /tests/test_reporter.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import shutil 4 | import subprocess 5 | 6 | from codeclimate_test_reporter.components.reporter import Reporter 7 | from codeclimate_test_reporter.components.argument_parser import ArgumentParser 8 | from .utils import ApiMock 9 | 10 | 11 | def test_run(): 12 | os.environ["CODECLIMATE_API_HOST"] = "http://example.com" 13 | 14 | api_mock = ApiMock() 15 | api_mock.setup(200) 16 | 17 | parsed_args = ArgumentParser().parse_args(["--file", "./coverage.txt", "--token", "token"]) 18 | reporter = Reporter(parsed_args) 19 | 20 | orig_dir = os.getcwd() 21 | os.chdir("./tests/fixtures") 22 | 23 | subprocess.call(["git", "init"]) 24 | subprocess.call(["git", "config", "user.name", "Test User"]) 25 | subprocess.call(["git", "config", "user.email", "test@example.com"]) 26 | subprocess.call(["git", "commit", "--allow-empty", "--message", "init"]) 27 | 28 | try: 29 | return_code = reporter.run() 30 | 31 | assert(return_code == 0) 32 | finally: 33 | del os.environ["CODECLIMATE_API_HOST"] 34 | os.chdir(orig_dir) 35 | shutil.rmtree("./tests/fixtures/.git") 36 | -------------------------------------------------------------------------------- /tests/test_runner.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import shutil 4 | import subprocess 5 | import sys 6 | 7 | if sys.version_info >= (3, 0): 8 | from io import StringIO 9 | else: 10 | from StringIO import StringIO 11 | 12 | from codeclimate_test_reporter.components.runner import Runner 13 | from codeclimate_test_reporter.__init__ import __version__ as reporter_version 14 | from .utils import ApiMock 15 | 16 | 17 | def test_version(): 18 | out = StringIO() 19 | runner = Runner(["--version"], out=out) 20 | 21 | return_code = runner.run() 22 | 23 | assert(return_code == 0) 24 | assert(out.getvalue().strip() == reporter_version) 25 | 26 | def test_run(): 27 | os.environ["CODECLIMATE_API_HOST"] = "http://example.com" 28 | 29 | api_mock = ApiMock() 30 | api_mock.setup(200) 31 | 32 | runner = Runner(["--file", "./coverage.txt", "--token", "token"]) 33 | 34 | orig_dir = os.getcwd() 35 | os.chdir("./tests/fixtures") 36 | 37 | subprocess.call(["git", "init"]) 38 | subprocess.call(["git", "config", "user.name", "Test User"]) 39 | subprocess.call(["git", "config", "user.email", "test@example.com"]) 40 | subprocess.call(["git", "commit", "--allow-empty", "--message", "init"]) 41 | 42 | try: 43 | return_code = runner.run() 44 | 45 | assert(return_code == 0) 46 | finally: 47 | del os.environ["CODECLIMATE_API_HOST"] 48 | os.chdir(orig_dir) 49 | shutil.rmtree("./tests/fixtures/.git") 50 | api_mock.cleanup() 51 | 52 | def test_run_api_500_error(): 53 | os.environ["CODECLIMATE_API_HOST"] = "http://example.com" 54 | 55 | api_mock = ApiMock() 56 | api_mock.setup(500) 57 | 58 | err = StringIO() 59 | runner = Runner(["--file", "./coverage.txt", "--token", "token"], err=err) 60 | 61 | orig_dir = os.getcwd() 62 | os.chdir("./tests/fixtures") 63 | 64 | subprocess.call(["git", "init"]) 65 | subprocess.call(["git", "config", "user.name", "Test User"]) 66 | subprocess.call(["git", "config", "user.email", "test@example.com"]) 67 | subprocess.call(["git", "commit", "--allow-empty", "--message", "init"]) 68 | 69 | try: 70 | return_code = runner.run() 71 | 72 | assert(return_code == 1) 73 | assert("500 Server Error" in err.getvalue().strip()) 74 | finally: 75 | del os.environ["CODECLIMATE_API_HOST"] 76 | os.chdir(orig_dir) 77 | shutil.rmtree("./tests/fixtures/.git") 78 | api_mock.cleanup() 79 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import httpretty 3 | 4 | 5 | class ApiMock: 6 | def setup(self, status_code): 7 | httpretty.enable() 8 | httpretty.register_uri( 9 | httpretty.POST, 10 | "http://example.com/test_reports", 11 | status=status_code, 12 | content_type="application/plain" 13 | ) 14 | 15 | def cleanup(self): 16 | httpretty.disable() 17 | httpretty.reset() 18 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py27,py34,py35 3 | 4 | [testenv] 5 | commands=python setup.py test 6 | deps=-rrequirements.txt 7 | 8 | [testenv:py35] 9 | commands=python setup.py testcov 10 | python -m codeclimate_test_reporter 11 | passenv=CODECLIMATE_REPO_TOKEN 12 | --------------------------------------------------------------------------------