Skip to content

Commit

Permalink
Merge pull request #77 from mrb/code_climate_support
Browse files Browse the repository at this point in the history
feat(*): add support for Code Climate
  • Loading branch information
rubik committed Nov 14, 2015
2 parents 5269174 + 19521d3 commit aefad45
Show file tree
Hide file tree
Showing 8 changed files with 238 additions and 10 deletions.
16 changes: 12 additions & 4 deletions .codeclimate.yml
@@ -1,5 +1,13 @@
languages:
Python: true
engines:
radon:
enabled: true
config:
threshold: 'B'
pep8:
enabled: true
ratings:
paths:
- "**.py"
exclude_paths:
- "docs/*"
- "radon/tests/*"
- "docs/*"
- "radon/tests/*"
19 changes: 19 additions & 0 deletions Dockerfile
@@ -0,0 +1,19 @@
FROM python:3.5
MAINTAINER Rubik

WORKDIR /usr/src/app

COPY tox_requirements.txt /usr/src/app/
RUN pip install -r tox_requirements.txt

RUN adduser -u 9000 app
COPY . /usr/src/app
RUN pip install .

WORKDIR /code

USER app

VOLUME /code

CMD ["/usr/src/app/codeclimate-radon"]
27 changes: 27 additions & 0 deletions codeclimate-radon
@@ -0,0 +1,27 @@
#!/usr/bin/env python

import json
import os.path

threshold = "b"
include_paths = ["."]

if os.path.exists("/config.json"):
contents = open("/config.json").read()
config = json.loads(contents)

if config.get("config") and config["config"].get("threshold"):
threshold = config["config"]["threshold"].lower()

if config.get("include_paths"):
config_paths = config.get("include_paths")
python_paths = []
for i in config_paths:
ext = os.path.splitext(i)[1]
if os.path.isdir(i) or "py" in ext:
python_paths.append(i)
include_paths = python_paths

if len(include_paths) > 0:
args = " ".join(include_paths)
os.system("radon cc {0} -n{1} --codeclimate".format(args, threshold))
13 changes: 9 additions & 4 deletions radon/cli/__init__.py
Expand Up @@ -16,7 +16,7 @@
@program.arg('paths', nargs='+')
def cc(paths, min='A', max='F', show_complexity=False, average=False,
exclude=None, ignore=None, order='SCORE', json=False, no_assert=False,
show_closures=False, total_average=False, xml=False):
show_closures=False, total_average=False, xml=False, codeclimate=False):
'''Analyze the given Python modules and compute Cyclomatic
Complexity (CC).
Expand All @@ -43,6 +43,7 @@ def cc(paths, min='A', max='F', show_complexity=False, average=False,
ALPHA.
:param -j, --json: Format results in JSON.
:param --xml: Format results in XML (compatible with CCM).
:param --codeclimate: Format results for Code Climate.
:param --no-assert: Do not count `assert` statements when computing
complexity.
:param --show-closures: Add closures to the output.
Expand All @@ -60,7 +61,7 @@ def cc(paths, min='A', max='F', show_complexity=False, average=False,
show_closures=show_closures,
)
harvester = CCHarvester(paths, config)
log_result(harvester, json=json, xml=xml)
log_result(harvester, json=json, xml=xml, codeclimate=codeclimate)


@program.command
Expand Down Expand Up @@ -170,14 +171,17 @@ def log_result(harvester, **kwargs):
Keywords parameters determine how the results are formatted. If *json* is
`True`, then `harvester.as_json()` is called. If *xml* is `True`, then
`harvester.as_xml()` is called.
`harvester.as_xml()` is called. If *codeclimate* is True, then
`harvester.as_codeclimate_issues()` is called.
Otherwise, `harvester.to_terminal()` is executed and `kwargs` is directly
passed to the :func:`~radon.cli.log` function.
'''
if kwargs.get('json'):
log(harvester.as_json(), noformat=True)
elif kwargs.get('xml'):
log(harvester.as_xml(), noformat=True)
elif kwargs.get('codeclimate'):
log_list(harvester.as_codeclimate_issues(), delimiter='\0', noformat=True)
else:
for msg, args, kwargs in harvester.to_terminal():
if kwargs.get('error', False):
Expand All @@ -198,8 +202,9 @@ def log(msg, *args, **kwargs):
in any way.
'''
indent = 4 * kwargs.get('indent', 0)
delimiter = kwargs.get('delimiter', '\n')
m = msg if kwargs.get('noformat', False) else msg.format(*args)
sys.stdout.write(' ' * indent + m + '\n')
sys.stdout.write(' ' * indent + m + delimiter)


def log_list(lst, *args, **kwargs):
Expand Down
10 changes: 9 additions & 1 deletion radon/cli/harvest.py
Expand Up @@ -7,7 +7,7 @@
from radon.complexity import cc_visit, sorted_results, cc_rank, add_closures
from radon.cli.colors import RANKS_COLORS, MI_RANKS, RESET
from radon.cli.tools import (iter_filenames, _open, cc_to_dict, dict_to_xml,
cc_to_terminal, raw_to_dict)
dict_to_codeclimate_issues, cc_to_terminal, raw_to_dict)


class Harvester(object):
Expand Down Expand Up @@ -95,6 +95,10 @@ def as_xml(self):
'''Format the results as XML.'''
raise NotImplementedError

def as_codeclimate_issues(self):
'''Format the results as Code Climate issues.'''
raise NotImplementedError

def to_terminal(self):
'''Yields tuples representing lines to be printed to a terminal.
Expand Down Expand Up @@ -137,6 +141,10 @@ def as_xml(self):
'''
return dict_to_xml(self._to_dicts())

def as_codeclimate_issues(self):
'''Format the result as Code Climate issues.'''
return dict_to_codeclimate_issues(self._to_dicts(), self.config.min)

def to_terminal(self):
'''Yield lines to be printed in a terminal.'''
average_cc = .0
Expand Down
116 changes: 116 additions & 0 deletions radon/cli/tools.py
@@ -1,7 +1,9 @@
'''This module contains various utility functions used in the CLI interface.'''

import os
import re
import sys
import json
import fnmatch
import xml.etree.cElementTree as et
from contextlib import contextmanager
Expand Down Expand Up @@ -123,6 +125,47 @@ def dict_to_xml(results):
return et.tostring(ccm).decode('utf-8')


def dict_to_codeclimate_issues(results, threshold='B'):
'''Convert a dictionary holding CC analysis results into Code Climate
issue json.'''
codeclimate_issues = []
content = get_content()
error_content = 'We encountered an error attempting to analyze this line.'

for path in results:
info = results[path]
if type(info) is dict and info.get('error'):
description = "Error: {0}".format(info.get('error', error_content))
beginline = re.search(r'\d+', description)
error_category = 'Bug Risk'

if beginline:
beginline = beginline.group()
else:
beginline = 1

endline = beginline
remediation_points = 1000000
codeclimate_issues.append(
format_cc_issue(path, description, error_content,
error_category, beginline, endline, remediation_points))
else:
for offender in info:
beginline = offender['lineno']
endline = offender['endline']
complexity = offender['complexity']
category = 'Complexity'
description = 'Cyclomatic complexity is too high in {0} {1}. ({2})'.format(
offender['type'], offender['name'], complexity)
remediation_points = get_remediation_points(complexity, threshold)

if remediation_points > 0:
codeclimate_issues.append(
format_cc_issue(path, description, content, category,
beginline, endline, remediation_points))
return codeclimate_issues


def cc_to_terminal(results, show_complexity, min, max, total_average):
'''Transfom Cyclomatic Complexity results into a 3-elements tuple:
Expand Down Expand Up @@ -168,3 +211,76 @@ def _format_line(block, ranked, show_complexity=False):
return TEMPLATE.format(BRIGHT, letter_colored, block.lineno,
block.col_offset, block.fullname, rank_colored,
compl, reset=RESET)


def format_cc_issue(path, description, content, category, beginline, endline, remediation_points):
'''Return properly formatted Code Climate issue json.'''
issue = {
'type':'issue',
'check_name':'Complexity',
'description': description,
'content': {
'body': content,
},
'categories': [category],
'location': {
'path': path,
'lines': {
'begin': beginline,
'end': endline,
},
},
'remediation_points': remediation_points,
}
return json.dumps(issue)


def get_remediation_points(complexity, grade_threshold):
'''Calculate quantity of remediation work needed to reduce complexity to grade
threshold permitted.'''
grade_to_max_permitted_cc = {
"B":5,
"C":10,
"D":20,
"E":30,
"F":40,
}

threshold = grade_to_max_permitted_cc.get(grade_threshold, 5)

if complexity and complexity > threshold:
return 1000000 + 100000 * (complexity - threshold)
else:
return 0


def get_content():
'''Return explanation string for Code Climate issue document.'''
content = """##Cyclomatic Complexity
Cyclomatic Complexity corresponds to the number of decisions a block of code
contains plus 1. This number (also called McCabe number) is equal to the number
of linearly independent paths through the code. This number can be used as a
guide when testing conditional logic in blocks.
Radon analyzes the AST tree of a Python program to compute Cyclomatic
Complexity. Statements have the following effects on Cyclomatic Complexity:\n
| Construct | Effect on CC | Reasoning |
| --------- | ------------------------------- | ---- |
| if | +1 | An *if* statement is a single decision. |
| elif| +1| The *elif* statement adds another decision. |
| else| +0| The *else* statement does not cause a new decision. The decision is at the *if*. |
| for| +1| There is a decision at the start of the loop. |
| while| +1| There is a decision at the *while* statement. |
| except| +1| Each *except* branch adds a new conditional path of execution. |
| finally| +0| The finally block is unconditionally executed. |
| with| +1| The *with* statement roughly corresponds to a try/except block (see PEP 343 for details). |
| assert| +1| The *assert* statement internally roughly equals a conditional statement. |
| Comprehension| +1| A list/set/dict comprehension of generator expression is equivalent to a for loop. |
| Lambda| +1| A lambda function is a regular function. |
| Boolean Operator| +1| Every boolean operator (and, or) adds a decision point. |
Source: http://radon.readthedocs.org/en/latest/intro.html"""
return content


2 changes: 1 addition & 1 deletion radon/tests/test_cli.py
Expand Up @@ -68,7 +68,7 @@ def test_cc(self, harv_mock, log_mock):
min='A', max='F', exclude=None, ignore=None, show_complexity=False,
average=False, order=getattr(cc_mod, 'SCORE'), no_assert=False,
total_average=False, show_closures=False))
log_mock.assert_called_once_with(mock.sentinel.harvester, json=True,
log_mock.assert_called_once_with(mock.sentinel.harvester, codeclimate=False, json=True,
xml=False)

@mock.patch('radon.cli.log_result')
Expand Down
45 changes: 45 additions & 0 deletions radon/tests/test_cli_tools.py
@@ -1,5 +1,6 @@
import os
import sys
import json
import unittest
import radon.cli.tools as tools
from radon.visitors import Function, Class
Expand Down Expand Up @@ -157,6 +158,17 @@ def testCCToDict(self):
]


CC_TO_CODECLIMATE_CASE = [
{'closures': [], 'endline': 16, 'complexity': 6, 'lineno': 12, 'type':
'function', 'name': 'foo', 'col_offset': 0, 'rank': 'B'},

{'complexity': 8, 'endline': 29, 'rank': 'B', 'lineno': 17, 'type': 'class',
'name': 'Classname', 'col_offset': 0},

{'closures': [], 'endline': 17, 'complexity': 4, 'lineno': 13, 'type':
'method', 'name': 'bar', 'col_offset': 4, 'rank': 'A'},
]

class TestDictConversion(unittest.TestCase):

def test_raw_to_dict(self):
Expand Down Expand Up @@ -209,6 +221,39 @@ def test_cc_to_xml(self):
</metric>
</ccm>'''.replace('\n', '').replace(' ', ''))

def test_cc_to_codeclimate(self):
actual_results = tools.dict_to_codeclimate_issues({'filename': CC_TO_CODECLIMATE_CASE})
expected_results = [
json.dumps({
"description":"Cyclomatic complexity is too high in function foo. (6)",
"check_name":"Complexity",
"content": { "body": tools.get_content()},
"location": { "path": "filename", "lines": {"begin": 12, "end": 16}},
"type":"issue",
"categories": ["Complexity"],
"remediation_points": 1100000
}),
json.dumps({
"description":"Cyclomatic complexity is too high in class Classname. (8)",
"check_name":"Complexity",
"content": {"body": tools.get_content()},
"location": {"path": "filename", "lines": {"begin": 17, "end": 29}},
"type":"issue",
"categories": ["Complexity"],
"remediation_points": 1300000
}),
]

actual_sorted = []
for i in actual_results:
actual_sorted.append(json.loads(i))

expected_sorted = []
for i in expected_results:
expected_sorted.append(json.loads(i))

self.assertEqual(actual_sorted, expected_sorted)


CC_TO_TERMINAL_CASES = [
Class(name='Classname', lineno=17, col_offset=0, endline=29,
Expand Down

0 comments on commit aefad45

Please sign in to comment.