diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..848a65d --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,30 @@ +--- +engines: + duplication: + enabled: true + config: + languages: + - python + exclude_paths: + # duplicate structures in dictionary + - reporter/components/ci.py + fixme: + enabled: true + pep8: + enabled: true + radon: + enabled: true + exclude_fingerprints: + # complexity in PayloadValidator#validate + - f57544b97810662ecf17d3c4be4974bc + # complexity in Runner#run + - f8db204cc016d9d612e462dc9ff6fcfb +ratings: + paths: + - "**.inc" + - "**.module" + - "**.py" +exclude_paths: +- dist/ +- reporter/tests/ +- build/ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0a54542 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +**/*.pyc +.git +.coverage diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1abddd --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# 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 + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# dotenv +.env diff --git a/.pypirc.sample b/.pypirc.sample new file mode 100644 index 0000000..6ba770d --- /dev/null +++ b/.pypirc.sample @@ -0,0 +1,14 @@ +[distutils] +index-servers = + pypi + pypitest + +[pypi] +repository=https://pypi.python.org/pypi +username= +password= + +[pypitest] +repository=https://testpypi.python.org/pypi +username= +password= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..46a2759 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.5.1-alpine + +WORKDIR /usr/src/app + +RUN apk --update add git + +COPY requirements.txt /usr/src/app/ +RUN pip install --upgrade pip && \ + pip install -r requirements.txt + +COPY . /usr/src/app + +RUN python setup.py install + +RUN adduser -u 9000 -D app +RUN chown -R app:app /usr/src/app +USER app + +ENTRYPOINT ["codeclimate-test-reporter"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fee41f4 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +.PHONY: all citest image run test release test-release + +IMAGE_NAME ?= codeclimate/python-test-reporter + +all: image + +citest: + docker run \ + --rm \ + --env CIRCLECI \ + --env CIRCLE_BRANCH \ + --env CIRCLE_SHA1 \ + --env CODECLIMATE_REPO_TOKEN \ + --entrypoint=/bin/sh \ + $(IMAGE_NAME) -c 'python setup.py test && codeclimate-test-reporter' + +image: + docker build --tag $(IMAGE_NAME) . + +run: image + docker run --rm $(IMAGE_NAME) + +test: image + docker run \ + -it \ + --rm \ + --entrypoint=/bin/sh \ + $(IMAGE_NAME) -c 'python setup.py test' + +release: image + docker run \ + --rm \ + --volume ~/.pypirc:/home/app/.pypirc \ + --entrypoint=/bin/sh \ + $(IMAGE_NAME) -c 'bin/release' + +test-release: image + docker run \ + --rm \ + --volume ~/.pypirc:/home/app/.pypirc \ + --entrypoint=/bin/sh \ + $(IMAGE_NAME) -c 'bin/test-release' diff --git a/bin/prep-release b/bin/prep-release new file mode 100755 index 0000000..e259449 --- /dev/null +++ b/bin/prep-release @@ -0,0 +1,46 @@ +#!/bin/sh +# +# Open a PR for releasing a new version of this repository. +# +# Usage: bin/prep-release VERSION +# +### +set -e + +if [ -z "$1" ]; then + echo "usage: bin/prep-release VERSION" >&2 + exit 64 +fi + +version=$1 +old_version=$(< VERSION) +branch="release-$version" + +if ! python setup.py test then + echo "test failure, not releasing" >&2 + exit 1 +fi + +printf "RELEASE %s => %s\n" "$old_version" "$version" +git checkout master +git pull + +git checkout -b "$branch" + +printf "%s\n" "$version" > VERSION +git add VERSION +git commit -m "Release v$version" +git push origin "$branch" + +branch_head=$(git rev-parse --short $branch) +if command -v hub > /dev/null 2>&1; then + hub pull-request -F - <&2 +fi + +echo "After merging the version-bump PR, run bin/release" diff --git a/bin/release b/bin/release new file mode 100755 index 0000000..88f1470 --- /dev/null +++ b/bin/release @@ -0,0 +1,16 @@ +#!/bin/sh +# +# Release a new version of this repository. +# +# Assumes bin/prep-release was run and the PR merged. +# +# Usage: bin/release +# +### + +python setup.py build +python setup.py register -r pyp +python setup.py sdist upload -r pypi + +git tag v$(cat reporter/VERSION) +git push origin --tags diff --git a/bin/test-release b/bin/test-release new file mode 100755 index 0000000..398f61b --- /dev/null +++ b/bin/test-release @@ -0,0 +1,11 @@ +#!/bin/sh +# +# Test release a new version of this repository. +# +# Usage: bin/test-release +# +### + +python setup.py build +python setup.py register -r pypitest +python setup.py sdist upload -r pypitest diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..37c162b --- /dev/null +++ b/circle.yml @@ -0,0 +1,15 @@ +machine: + services: + - docker + +dependencies: + override: + - make image + +test: + override: + - make citest + +notify: + webhooks: + - url: https://cc-slack-proxy.herokuapp.com/circle diff --git a/reporter/VERSION b/reporter/VERSION new file mode 100644 index 0000000..8acdd82 --- /dev/null +++ b/reporter/VERSION @@ -0,0 +1 @@ +0.0.1 diff --git a/reporter/__init__.py b/reporter/__init__.py new file mode 100644 index 0000000..3879983 --- /dev/null +++ b/reporter/__init__.py @@ -0,0 +1,9 @@ +""" +codeclimate-test-reporter +""" + +import os + +__author__ = "Code Climate" +__version__ = open(os.path.join(os.path.dirname(__file__), "VERSION")).read().strip() +__license__ = "MIT" diff --git a/reporter/__main__.py b/reporter/__main__.py new file mode 100755 index 0000000..1d342b1 --- /dev/null +++ b/reporter/__main__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python + +import sys + +from .components.runner import Runner + + +def run(): + runner = Runner() + + sys.exit(runner.run()) diff --git a/reporter/components/__init__.py b/reporter/components/__init__.py new file mode 100644 index 0000000..b6e690f --- /dev/null +++ b/reporter/components/__init__.py @@ -0,0 +1 @@ +from . import * diff --git a/reporter/components/api_client.py b/reporter/components/api_client.py new file mode 100644 index 0000000..58208ec --- /dev/null +++ b/reporter/components/api_client.py @@ -0,0 +1,25 @@ +import json +import os +import requests + + +class ApiClient: + def __init__(self, host=None, timeout=5): + self.host = host or self.__default_host() + self.timeout = timeout + + def post(self, payload): + print("Submitting payload to %s" % self.host) + + headers = {"Content-Type": "application/json"} + response = requests.post( + "%s/test_reports" % self.host, + data=json.dumps(payload), + headers=headers, + timeout=self.timeout + ) + + return response + + def __default_host(self): + return os.environ.get("CODECLIMATE_HOST", "https://codeclimate.com") diff --git a/reporter/components/argument_parser.py b/reporter/components/argument_parser.py new file mode 100644 index 0000000..a86aa9f --- /dev/null +++ b/reporter/components/argument_parser.py @@ -0,0 +1,26 @@ +""" +CLI arguments definition. +""" + +import argparse +import sys + + +class ArgumentParser(argparse.ArgumentParser): + def __init__(self): + argparse.ArgumentParser.__init__( + self, + prog="codeclimate-test-reporter", + description="Report test coverage to Code Climate" + ) + + self.add_argument( + "--file", + help="A coverage.py coverage file to report", + default="./.coverage" + ) + + self.add_argument("--token", help="Code Climate repo token") + self.add_argument("--stdout", help="Output to STDOUT", action="store_true") + self.add_argument("--debug", help="Enable debug mode", action="store_true") + self.add_argument("--version", help="Show the version", action="store_true") diff --git a/reporter/components/ci.py b/reporter/components/ci.py new file mode 100644 index 0000000..9d071cb --- /dev/null +++ b/reporter/components/ci.py @@ -0,0 +1,132 @@ +import os +import re + +import itertools + + +class CI: + def __init__(self, env=os.environ): + self.env = env + + def predicate(self, service): + return service["matcher"](self.env) + + def data(self): + service = next(filter(self.predicate, self.__services()), None) + + if service: + return service["data"](self.env) + else: + return {} + + def __services(self): + return [{ + "matcher": lambda env: env.get("TRAVIS"), + "data": lambda env: { + "name": "travis-ci", + "branch": self.env.get("TRAVIS_BRANCH"), + "build_identifier": self.env.get("TRAVIS_JOB_ID"), + "pull_request": self.env.get("TRAVIS_PULL_REQUEST") + } + }, { + "matcher": lambda env: env.get("CIRCLECI"), + "data": lambda env: { + "name": "circleci", + "branch": self.env.get("CIRCLE_BRANCH"), + "build_identifier": self.env.get("CIRCLE_BUILD_NUM"), + "commit_sha": self.env.get("CIRCLE_SHA1") + } + }, { + "matcher": lambda env: env.get("SEMAPHORE"), + "data": lambda env: { + "name": "semaphore", + "branch": self.env.get("BRANCH_NAME"), + "build_identifier": self.env.get("SEMAPHORE_BUILD_NUMBER") + } + }, { + "matcher": lambda env: env.get("JENKINS_URL"), + "data": lambda env: { + "name": "jenkins", + "build_identifier": self.env.get("BUILD_NUMBER"), + "build_url": self.env.get("BUILD_URL"), + "branch": self.env.get("GIT_BRANCH"), + "commit_sha": self.env.get("GIT_COMMIT") + } + }, { + "matcher": lambda env: env.get("TDDIUM"), + "data": lambda env: { + "name": "tddium", + "build_identifier": self.env.get("TDDIUM_SESSION_ID"), + "worker_id": self.env.get("TDDIUM_TID") + } + }, { + "matcher": lambda env: env.get("WERCKER"), + "data": lambda env: { + "name": "wercker", + "build_identifier": self.env.get("WERCKER_BUILD_ID"), + "build_url": self.env.get("WERCKER_BUILD_URL"), + "branch": self.env.get("WERCKER_GIT_BRANCH"), + "commit_sha": self.env.get("WERCKER_GIT_COMMIT") + } + }, { + "matcher": lambda env: env.get("APPVEYOR"), + "data": lambda env: { + "name": "appveyor", + "build_identifier": self.env.get("APPVEYOR_BUILD_ID"), + "build_url": self.env.get("APPVEYOR_API_URL"), + "branch": self.env.get("APPVEYOR_REPO_BRANCH"), + "commit_sha": self.env.get("APPVEYOR_REPO_COMMIT"), + "pull_request": self.env.get("APPVEYOR_PULL_REQUEST_NUMBER") + } + }, { + "matcher": lambda env: self.__ci_name_match("DRONE"), + "data": lambda env: { + "name": "drone", + "build_identifier": self.env.get("CI_BUILD_NUMBER"), + "build_url": self.env.get("CI_BUILD_URL"), + "branch": self.env.get("CI_BRANCH"), + "commit_sha": self.env.get("CI_BUILD_NUMBER"), + "pull_request": self.env.get("CI_PULL_REQUEST") + } + }, { + "matcher": lambda env: self.__ci_name_match("CODESHIP"), + "data": lambda env: { + "name": "codeship", + "build_identifier": self.env.get("CI_BUILD_NUMBER"), + "build_url": self.env.get("CI_BUILD_URL"), + "branch": self.env.get("CI_BRANCH"), + "commit_sha": self.env.get("CI_COMMIT_ID") + } + }, { + "matcher": lambda env: self.__ci_name_match("VEXOR"), + "data": lambda env: { + "name": "vexor", + "build_identifier": self.env.get("CI_BUILD_NUMBER"), + "build_url": self.env.get("CI_BUILD_URL"), + "branch": self.env.get("CI_BRANCH"), + "commit_sha": self.env.get("CI_BUILD_SHA"), + "pull_request": self.env.get("CI_PULL_REQUEST_ID") + } + }, { + "matcher": lambda env: env.get("BUILDKITE"), + "data": lambda env: { + "name": "buildkite", + "build_identifier": self.env.get("BUILDKITE_JOB_ID"), + "build_url": self.env.get("BUILDKITE_BUILD_URL"), + "branch": self.env.get("BUILDKITE_BRANCH"), + "commit_sha": self.env.get("BUILDKITE_COMMIT"), + } + }, { + "matcher": lambda env: env.get("GITLAB_CI"), + "data": lambda env: { + "name": "gitlab-ci", + "build_identifier": self.env.get("CI_BUILD_ID"), + "branch": self.env.get("CI_BUILD_REF_NAME"), + "commit_sha": self.env.get("CI_BUILD_REF"), + } + }] + + def __ci_name_match(self, pattern): + ci_name = self.env.get("CI_NAME") + + return ci_name and re.match(pattern, ci_name, re.IGNORECASE) diff --git a/reporter/components/file_coverage.py b/reporter/components/file_coverage.py new file mode 100644 index 0000000..1d70ef2 --- /dev/null +++ b/reporter/components/file_coverage.py @@ -0,0 +1,81 @@ +import json +from hashlib import sha1 + + +class FileCoverage: + def __init__(self, file_node): + self.file_node = file_node + self.__process() + + def payload(self): + return { + "name": self.__filename(), + "blob_id": self.__blob(), + "covered_strength": self.__covered_strength(), + "covered_percent": self.__covered_percent(), + "coverage": json.dumps(self.__coverage()), + "line_counts": self.__line_counts() + } + + def __process(self): + self.total = len(self.__line_nodes()) + self.hits = 0 + self.covered = 0 + self.missed = 0 + + for line_node in self.__line_nodes(): + hits = int(line_node.get("hits")) + + if hits > 0: + self.covered += 1 + self.hits += hits + else: + self.missed += 1 + + def __line_nodes(self): + return self.file_node.findall("lines/line") + + def __blob(self): + contents = open(self.__filename(), "r").read() + header = "blob " + str(len(contents)) + "\0" + + return sha1((header + contents).encode("utf-8")).hexdigest() + + def __filename(self): + return self.file_node.get("filename") + + def __rate(self): + return self.file_node.get("line-rate") + + def __covered_strength(self): + return self.__guard_division(self.hits, self.covered) + + def __num_lines(self): + return sum(1 for line in open(self.__filename())) + + def __covered_percent(self): + return self.__guard_division(self.covered, self.total) + + def __guard_division(self, dividend, divisor): + if (divisor > 0): + return dividend / float(divisor) + else: + return 0.0 + + def __coverage(self): + coverage = [None] * self.__num_lines() + + for line_node in self.__line_nodes(): + index = int(line_node.get("number")) - 1 + hits = int(line_node.get("hits")) + + coverage[index] = hits + + return coverage + + def __line_counts(self): + return { + "total": self.total, + "covered": self.covered, + "missed": self.missed + } diff --git a/reporter/components/formatter.py b/reporter/components/formatter.py new file mode 100644 index 0000000..0c09677 --- /dev/null +++ b/reporter/components/formatter.py @@ -0,0 +1,73 @@ +import xml.etree.ElementTree as ET + +from ..__init__ import __version__ as reporter_version +from .ci import CI +from .file_coverage import FileCoverage +from .git_command import GitCommand + + +class Formatter: + def __init__(self, xml_report_path): + tree = ET.parse(xml_report_path) + self.root = tree.getroot() + + def payload(self): + total_line_counts = {"covered": 0, "missed": 0, "total": 0} + total_covered_strength = 0.0 + total_covered_percent = 0.0 + + source_files = self.__source_files() + + for source_file in source_files: + total_covered_strength += source_file["covered_strength"] + total_covered_percent += source_file["covered_percent"] + + for key, value in source_file["line_counts"].items(): + total_line_counts[key] += value + + total_covered_strength = round(total_covered_strength / len(source_files), 2) + total_covered_percent = round(total_covered_percent / len(source_files), 2) + + return { + "run_at": self.__timestamp(), + "covered_percent": total_covered_percent, + "covered_strength": total_covered_strength, + "line_counts": total_line_counts, + "partial": False, + "git": self.__git_info(), + "environment": { + "pwd": self.root.find("sources").find("source").text, + "reporter_version": reporter_version + }, + "ci_service": self.__ci_data(), + "source_files": source_files + } + + def __ci_data(self): + return CI().data() + + def __source_files(self): + source_files = [] + + for file_node in self.__file_nodes(): + file_coverage = FileCoverage(file_node) + payload = file_coverage.payload() + source_files.append(payload) + + return source_files + + def __file_nodes(self): + return self.root.findall("packages/package/classes/class") + + def __timestamp(self): + return self.root.get("timestamp") + + def __git_info(self): + ci_data = self.__ci_data() + command = GitCommand() + + return { + "branch": ci_data.get("branch") or command.branch(), + "committed_at": command.committed_at(), + "head": ci_data.get("commit_sha") or command.head() + } diff --git a/reporter/components/git_command.py b/reporter/components/git_command.py new file mode 100644 index 0000000..4fc0033 --- /dev/null +++ b/reporter/components/git_command.py @@ -0,0 +1,22 @@ +import os +import subprocess + + +class GitCommand: + def branch(self): + return self.__execute("git rev-parse --abbrev-ref HEAD") + + def committed_at(self): + return self.__execute("git log -1 --pretty=format:%ct") + + def head(self): + return self.__execute("git log -1 --pretty=format:'%H'") + + def __execute(self, command): + process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE) + exit_code = process.wait() + + if exit_code == 0: + return process.stdout.read().decode("utf-8").strip() + else: + return None diff --git a/reporter/components/payload_validator.py b/reporter/components/payload_validator.py new file mode 100644 index 0000000..e4461ef --- /dev/null +++ b/reporter/components/payload_validator.py @@ -0,0 +1,58 @@ +class InvalidPayload(Exception): + pass + + +class PayloadValidator: + def __init__(self, payload): + self.payload = payload + + def validate(self): + if not self.__commit_sha(): + raise InvalidPayload("A git commit sha was not found in the test report payload") + elif not self.__committed_at(): + raise InvalidPayload("A git commit timestamp was not found in the test report payload") + elif not self.__run_at(): + raise InvalidPayload("A run at timestamp was not found in the test report payload") + elif not self.__source_files(): + raise InvalidPayload("No source files were found in the test report payload") + elif not self.__valid_source_files(): + raise InvalidPayload("Invalid source files were found in the test report payload") + else: + return True + + def __commit_sha(self): + return self.__commit_sha_from_git() or self.__commit_sha_from_ci_service() + + def __commit_sha_from_git(self): + return self.__validate_payload_value(["git", "head"]) + + def __commit_sha_from_ci_service(self): + return self.__validate_payload_value(["ci_service", "commit_sha"]) + + def __committed_at(self): + return self.__validate_payload_value(["git", "committed_at"]) + + def __run_at(self): + return self.payload.get("run_at") + + def __source_files(self): + return self.payload.get("source_files") + + def __validate_payload_value(self, keys): + current = self.payload + + for key in keys: + next = current.get(key) + + if next: + current = next + else: + return False + + return True + + def __valid_source_files(self): + return all(self.__valid_source_file(source_file) for source_file in self.__source_files()) + + def __valid_source_file(self, source_file): + return type(source_file) is dict and source_file.get("name") and source_file.get("coverage") diff --git a/reporter/components/reporter.py b/reporter/components/reporter.py new file mode 100644 index 0000000..c52ba50 --- /dev/null +++ b/reporter/components/reporter.py @@ -0,0 +1,65 @@ +import coverage as Coverage +import json +import os +import sys + +from ..components.api_client import ApiClient +from ..components.formatter import Formatter +from ..components.payload_validator import PayloadValidator + + +class Reporter: + def __init__(self, args): + self.args = args + + def run(self): + """ + The main program without error handling + + :param args: parsed args (argparse.Namespace) + :return: status code + + """ + + xml_filepath = self.__create_xml_report(self.args.file) + formatter = Formatter(xml_filepath) + payload = formatter.payload() + + PayloadValidator(payload).validate() + + if self.args.stdout: + print(json.dumps(payload)) + + return 0 + else: + return self.__post_payload(payload) + + def __post_payload(self, payload): + client = ApiClient() + payload["repo_token"] = self.args.token or os.environ.get("CODECLIMATE_REPO_TOKEN") + + if payload["repo_token"]: + response = client.post(payload) + response.raise_for_status() + + return 0 + else: + print("CODECLIMATE_REPO_TOKEN is required and not set") + + return 1 + + def __create_xml_report(self, file): + cov = Coverage.coverage(file) + cov.load() + data = cov.get_data() + + xml_filepath = "/tmp/coverage.xml" + cov.xml_report(outfile=xml_filepath) + + return xml_filepath + + def __exit_code_for_status_code(self, status_code): + if status_code == 200: + return 0 + elif status_code == 500: + return 1 diff --git a/reporter/components/runner.py b/reporter/components/runner.py new file mode 100644 index 0000000..4f9afe0 --- /dev/null +++ b/reporter/components/runner.py @@ -0,0 +1,83 @@ +"""This module provides the main functionality of codeclimate-test-reporter. + +Invocation flow: + + 1. Read, validate and process the input (args, `stdin`). + 2. Create and send test coverage to codeclimate.com. + 3. Report back to `stdout`. + 4. Exit. + +""" +from coverage.misc import CoverageException +import os +import platform +import sys +import requests.exceptions + +from ..__init__ import __version__ as reporter_version +from ..components.argument_parser import ArgumentParser +from ..components.payload_validator import InvalidPayload +from ..components.reporter import Reporter + + +class Runner: + def run(self, args=sys.argv[1:], env=os.environ): + """ + The main function. + + Pre-process args, handle some special types of invocations, + and run the main program with error handling. + + Return exit status code. + + """ + + try: + parsed_args = ArgumentParser().parse_args(args) + + if parsed_args.debug: + sys.stderr.write(self.__debug_info()) + return 0 + elif parsed_args.version: + print(reporter_version) + return 0 + else: + reporter = Reporter(parsed_args) + exit_status = reporter.run() + return exit_status + except CoverageException as e: + return self.__handle_error( + "Coverage file `" + parsed_args.file + "` file not found. " + "Use --file to specifiy an alternate location." + ) + except InvalidPayload as e: + return self.__handle_error("Invalid Payload: " + str(e), support=True) + except requests.exceptions.HTTPError as e: + return self.__handle_error(str(e), support=True) + except requests.exceptions.Timeout: + return self.__handle_error( + "Client HTTP Timeout: No response in 5 seconds.", + support=True + ) + + def __handle_error(self, message, support=False): + sys.stderr.write(message) + + if support: + sys.stderr.write( + "\n\nContact support at https://codeclimate.com/help " + "with the following debug info if error persists:" + "\n" + message + "\n" + self.__debug_info() + ) + + return 1 + + def __debug_info(self): + from requests import __version__ as requests_version + + return "\n".join([ + "codeclimate-test-repoter %s" % reporter_version, + "Requests %s" % requests_version, + "Python %s\n%s" % (sys.version, sys.executable), + "%s %s" % (platform.system(), platform.release()), + ]) diff --git a/reporter/tests/__init__.py b/reporter/tests/__init__.py new file mode 100644 index 0000000..b6e690f --- /dev/null +++ b/reporter/tests/__init__.py @@ -0,0 +1 @@ +from . import * diff --git a/reporter/tests/fixtures/coverage.txt b/reporter/tests/fixtures/coverage.txt new file mode 100644 index 0000000..91f2b69 --- /dev/null +++ b/reporter/tests/fixtures/coverage.txt @@ -0,0 +1 @@ +!coverage.py: This is a private format, don't read it directly!{"lines": {"/Users/devonblandin/cc/python-test-reporter/reporter/tests/fixtures/source.py": [4, 5, 6, 7, 9, 10, 12, 15, 16]}} diff --git a/reporter/tests/fixtures/coverage.xml b/reporter/tests/fixtures/coverage.xml new file mode 100644 index 0000000..91dae9b --- /dev/null +++ b/reporter/tests/fixtures/coverage.xml @@ -0,0 +1,29 @@ + + + + + + /usr/src/app/reporter/tests/fixtures + + + + + + + + + + + + + + + + + + + + + + + diff --git a/reporter/tests/fixtures/source.py b/reporter/tests/fixtures/source.py new file mode 100644 index 0000000..3cee368 --- /dev/null +++ b/reporter/tests/fixtures/source.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python + + +class Person: + def __init__(self, first_name, last_name): + self.first_name = first_name + self.last_name = last_name + + def fullname(self): + return "%s %s" % (self.first_name, self.last_name) + + def not_called(self): + print("Shouldn't be called") + +person = Person("Marty", "McFly") +person.fullname() diff --git a/reporter/tests/test_ci.py b/reporter/tests/test_ci.py new file mode 100644 index 0000000..ce69564 --- /dev/null +++ b/reporter/tests/test_ci.py @@ -0,0 +1,56 @@ +import pytest + +from ..components.ci import CI + + +def test_travis_data(): + env = { + "TRAVIS": True, + "TRAVIS_BRANCH": "master", + "TRAVIS_JOB_ID": "4.1", + "TRAVIS_PULL_REQUEST": "false" + } + + expected_data = { + "name": "travis-ci", + "branch": env["TRAVIS_BRANCH"], + "build_identifier": env["TRAVIS_JOB_ID"], + "pull_request": env["TRAVIS_PULL_REQUEST"] + } + + data = CI(env).data() + + assert data == expected_data + +def test_circle_data(): + env = { + "CIRCLECI": True, + "CIRCLE_BRANCH": "master", + "CIRCLE_BUILD_NUM": "123", + "CIRCLE_SHA1": "7638417db6d59f3c431d3e1f261cc637155684cd" + } + + expected_data = { + "name": "circleci", + "branch": env["CIRCLE_BRANCH"], + "build_identifier": env["CIRCLE_BUILD_NUM"], + "commit_sha": env["CIRCLE_SHA1"] + } + + data = CI(env).data() + + assert data == expected_data + +def test_ci_pick(): + assert CI({ "TRAVIS": True }).data()["name"] == "travis-ci" + assert CI({ "CIRCLECI": True }).data()["name"] == "circleci" + assert CI({ "SEMAPHORE": True }).data()["name"] == "semaphore" + assert CI({ "JENKINS_URL": True }).data()["name"] == "jenkins" + assert CI({ "TDDIUM": True }).data()["name"] == "tddium" + assert CI({ "WERCKER": True }).data()["name"] == "wercker" + assert CI({ "APPVEYOR": True }).data()["name"] == "appveyor" + assert CI({ "CI_NAME": "DRONE" }).data()["name"] == "drone" + assert CI({ "CI_NAME": "CODESHIP" }).data()["name"] == "codeship" + assert CI({ "CI_NAME": "VEXOR" }).data()["name"] == "vexor" + assert CI({ "BUILDKITE": True }).data()["name"] == "buildkite" + assert CI({ "GITLAB_CI": True }).data()["name"] == "gitlab-ci" diff --git a/reporter/tests/test_formatter.py b/reporter/tests/test_formatter.py new file mode 100644 index 0000000..b456944 --- /dev/null +++ b/reporter/tests/test_formatter.py @@ -0,0 +1,30 @@ +import pytest +import subprocess + +from ..components.formatter import Formatter +from ..components.payload_validator import PayloadValidator + + +def test_formatter(): + subprocess.call(["git", "config", "--global", "user.email", "you@example.com"]) + subprocess.call(["git", "config", "--global", "user.name", "Your Name"]) + subprocess.call(["git", "init"]) + subprocess.call(["git", "add", "."]) + subprocess.call(["git", "commit", "-m", "init"]) + + formatter = Formatter("./reporter/tests/fixtures/coverage.xml") + payload = formatter.payload() + + assert type(payload) is dict + assert len(payload["source_files"]) == 1 + + source_file = payload["source_files"][0] + expected_line_counts = {"covered": 9, "missed": 1, "total": 10} + expected_coverage = "[null, null, null, 1, 1, 1, 1, null, 1, 1, null, 1, 0, null, 1, 1]" + + assert source_file["line_counts"] == expected_line_counts + assert source_file["covered_percent"] == 0.9 + assert source_file["covered_strength"] == 1.0 + assert source_file["coverage"] == expected_coverage + + assert PayloadValidator(payload).validate() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a4dda05 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +requests +coverage==4.0.3 +pytest-cov +pytest +HTTPretty diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..24ce838 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[metadata] +description-file = README.md + +[pep8] +max-line-length = 100 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2657993 --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +from setuptools import Command, find_packages, setup +from subprocess import call + +from reporter.__init__ import __version__ as reporter_version + + +class RunTests(Command): + """Run all tests.""" + description = 'run tests' + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + """Run all tests!""" + errno = call(["py.test", "--cov=reporter", "reporter/tests/"]) + raise SystemExit(errno) + + +setup( + name="codeclimate-test-reporter", + version=reporter_version, + description="Report test coverage to Code Climate", + url="http://github.com/codeclimate/python-test-reporter", + author="Code Climate", + author_email="hello@codeclimate.com", + license="MIT", + packages=find_packages(exclude=["tests"]), + zip_safe=False, + cmdclass={"test": RunTests}, + entry_points={ + "console_scripts": [ + "codeclimate-test-reporter=reporter.__main__:run", + ], + }, + package_data={"reporter": ["VERSION"]}, +)