diff --git a/radon/raw_visitor.py b/radon/raw_visitor.py new file mode 100644 index 0000000..1c37dd9 --- /dev/null +++ b/radon/raw_visitor.py @@ -0,0 +1,278 @@ +from collections import namedtuple +import ast + +from radon.metrics import analyze +from radon.visitors import GET_ENDLINE, code2ast +from radon.cli.tools import raw_to_dict + +try: + from ast import get_source_segment +except ImportError: + raise ImportError("raw_visitor module requires Python >=3.8") + + +BaseRawFuncMetrics = namedtuple( + "BaseRawFuncMetrics", + [ + "name", + "lineno", + "col_offset", + "endline", + "is_method", + "classname", + "closures", + "loc", + "lloc", + "sloc", + "comments", + "multi", + "blank", + "single_comments", + ], +) + +BaseRawClassMetrics = namedtuple( + "BaseRawClassMetrics", + [ + "name", + "lineno", + "col_offset", + "endline", + "methods", + "inner_classes", + "loc", + "lloc", + "sloc", + "comments", + "multi", + "blank", + "single_comments", + ], +) + + +class RawFunctionMetrics(BaseRawFuncMetrics): + """Object represeting a function block.""" + + @property + def letter(self): + """The letter representing the function. It is `M` if the function is + actually a method, `F` otherwise. + """ + return "M" if self.is_method else "F" + + @property + def fullname(self): + """The full name of the function. If it is a method, then the full name + is: + {class name}.{method name} + Otherwise it is just the function name. + """ + if self.classname is None: + return self.name + return "{0}.{1}".format(self.classname, self.name) + + def __str__(self): + """String representation of a function block.""" + return "{0} {1}:{2}->{3} {4} - sloc: {5}".format( + self.letter, + self.lineno, + self.col_offset, + self.endline, + self.fullname, + self.sloc, + ) + + +class RawClassMetrics(BaseRawClassMetrics): + """Object representing a class block.""" + + letter = "C" + + @property + def fullname(self): + """The full name of the class. It is just its name. This attribute + exists for consistency (see :data:`RawFunctionMetrics.fullname`). + """ + return self.name + + def __str__(self): + """String representation of a class block.""" + return "{0} {1}:{2}->{3} {4} - sloc: {5}".format( + self.letter, + self.lineno, + self.col_offset, + self.endline, + self.name, + self.sloc, + ) + + +class CodeVisitor(ast.NodeVisitor): + """Base class for every NodeVisitors in `radon.visitors`. It implements a + couple utility class methods and a static method. + """ + + @staticmethod + def get_name(obj): + """Shorthand for ``obj.__class__.__name__``.""" + return obj.__class__.__name__ + + @classmethod + def from_code(cls, code, **kwargs): + """Instantiate the class from source code (string object). The + `**kwargs` are directly passed to the `ast.NodeVisitor` constructor. + """ + cls.code = code + node = code2ast(code) + return cls.from_ast(node, **kwargs) + + @classmethod + def from_ast(cls, ast_node, **kwargs): + """Instantiate the class from an AST node. The `**kwargs` are + directly passed to the `ast.NodeVisitor` constructor. + """ + visitor = cls(**kwargs) + visitor.visit(ast_node) + return visitor + + +class RawVisitor(CodeVisitor): + """A visitor that keeps track of raw metrics for block of code. + + Metrics are provided for functions, classes and class methods. + + :param to_method: If True, every function is treated as a method. In this + case the *classname* parameter is used as class name. + :param classname: Name of parent class. + :param off: If True, the starting value for the complexity is set to 1, + otherwise to 0. + """ + + def __init__(self, to_method=False, classname=None): + self.functions = [] + self.classes = [] + self.to_method = to_method + self.classname = classname + self._max_line = float("-inf") + + @property + def blocks(self): + """All the blocks visited. These include: all the functions, the + classes and their methods. The returned list is not sorted. + """ + blocks = [] + blocks.extend(self.functions) + for cls in self.classes: + blocks.append(cls) + blocks.extend(cls.methods) + return blocks + + @property + def max_line(self): + """The maximum line number among the analyzed lines.""" + return self._max_line + + @max_line.setter + def max_line(self, value): + """The maximum line number among the analyzed lines.""" + if value > self._max_line: + self._max_line = value + + def generic_visit(self, node): + """Main entry point for the visitor.""" + # Check for a lineno attribute + if hasattr(node, "lineno"): + self.max_line = node.lineno + + super(RawVisitor, self).generic_visit(node) + + def visit_AsyncFunctionDef(self, node): + """Async function definition is the same thing as the synchronous + one. + """ + self.visit_FunctionDef(node) + + def get_raw_metrics(self, node): + # astunparse.unparse() parses triple quote strings + # a single quote strings. A single quote string is + # interpreted as a sloc instead of a multi. + # source_segement = unparse(node) + + source_segment = get_source_segment(self.code, node) + raw_metrics = analyze(source_segment) + raw_metrics_dict = raw_to_dict(raw_metrics) + self.loc = raw_metrics_dict["loc"] + self.lloc = raw_metrics_dict["lloc"] + self.sloc = raw_metrics_dict["sloc"] + self.comments = raw_metrics_dict["comments"] + self.multi = raw_metrics_dict["multi"] + self.blank = raw_metrics_dict["blank"] + self.single_comments = raw_metrics_dict["single_comments"] + + def visit_FunctionDef(self, node): + """When visiting functions a new visitor is created to recursively + analyze the function's body. + """ + closures = [] + + for child in node.body: + visitor = RawVisitor() + visitor.visit(child) + closures.extend(visitor.functions) + + self.get_raw_metrics(node) + func_metrics = RawFunctionMetrics( + node.name, + node.lineno, + node.col_offset, + max(node.lineno, visitor.max_line), + self.to_method, + self.classname, + closures, + self.loc, + self.lloc, + self.sloc, + self.comments, + self.multi, + self.blank, + self.single_comments, + ) + + self.functions.append(func_metrics) + + def visit_ClassDef(self, node): + """When visiting classes a new visitor is created to recursively + analyze the class' body and methods. + """ + methods = [] + classname = node.name + visitors_max_lines = [node.lineno] + inner_classes = [] + for child in node.body: + visitor = RawVisitor( + True, + classname, + ) + visitor.visit(child) + methods.extend(visitor.functions) + visitors_max_lines.append(visitor.max_line) + inner_classes.extend(visitor.classes) + + self.get_raw_metrics(node) + cls_metrics = RawClassMetrics( + classname, + node.lineno, + node.col_offset, + max(visitors_max_lines + list(map(GET_ENDLINE, methods))), + methods, + inner_classes, + self.loc, + self.lloc, + self.sloc, + self.comments, + self.multi, + self.blank, + self.single_comments, + ) + self.classes.append(cls_metrics) diff --git a/radon/tests/test_raw.py b/radon/tests/test_raw.py index b9bd0f2..ec73a7b 100644 --- a/radon/tests/test_raw.py +++ b/radon/tests/test_raw.py @@ -136,40 +136,7 @@ def test_logical(code, expected_number_of_lines): assert _logical(code) == expected_number_of_lines -ANALYZE_CASES = [ - (''' - ''', (0, 0, 0, 0, 0, 0, 0)), - - (''' - """ - doc? - """ - ''', (3, 1, 0, 0, 3, 0, 0)), - - (''' - # just a comment - if a and b: - print('woah') - else: - # you'll never get here - print('ven') - ''', (6, 4, 4, 2, 0, 0, 2)), - - (''' - # - # - # - ''', (3, 0, 0, 3, 0, 0, 3)), - - (''' - if a: - print - - - else: - print - ''', (6, 4, 4, 0, 0, 2, 0)), - +VISITOR_CASES = [ # In this case the docstring is not counted as a multi-line string # because in fact it is on one line! (''' @@ -178,34 +145,6 @@ def f(n): return n * f(n - 1) ''', (3, 3, 2, 0, 0, 0, 1)), - (''' - def hip(a, k): - if k == 1: return a - # getting high... - return a ** hip(a, k - 1) - - def fib(n): - """Compute the n-th Fibonacci number. - - Try it with n = 294942: it will take a fairly long time. - """ - if n <= 1: return 1 # otherwise it will melt the cpu - return fib(n - 2) + fib(n - 1) - ''', (12, 9, 6, 2, 3, 2, 1)), - - (''' - a = [1, 2, 3, - ''', SyntaxError), - - # Test that handling of parameters with a value passed in. - (''' - def foo(n=1): - """ - Try it with n = 294942: it will take a fairly long time. - """ - if n <= 1: return 1 # otherwise it will melt the cpu - ''', (5, 4, 2, 1, 3, 0, 0)), - (''' def foo(n=1): """ @@ -332,8 +271,71 @@ def function(): ''', (2, 3, 2, 0, 0, 0, 0)), ] +MAIN_CASES = [ + (''' + ''', (0, 0, 0, 0, 0, 0, 0)), + + (''' + """ + doc? + """ + ''', (3, 1, 0, 0, 3, 0, 0)), + + (''' + # just a comment + if a and b: + print('woah') + else: + # you'll never get here + print('ven') + ''', (6, 4, 4, 2, 0, 0, 2)), + + (''' + # + # + # + ''', (3, 0, 0, 3, 0, 0, 3)), + + (''' + if a: + print + + + else: + print + ''', (6, 4, 4, 0, 0, 2, 0)), + + (''' + def hip(a, k): + if k == 1: return a + # getting high... + return a ** hip(a, k - 1) + + def fib(n): + """Compute the n-th Fibonacci number. + + Try it with n = 294942: it will take a fairly long time. + """ + if n <= 1: return 1 # otherwise it will melt the cpu + return fib(n - 2) + fib(n - 1) + ''', (12, 9, 6, 2, 3, 2, 1)), + + (''' + a = [1, 2, 3, + ''', SyntaxError), + + # Test that handling of parameters with a value passed in. + (''' + def foo(n=1): + """ + Try it with n = 294942: it will take a fairly long time. + """ + if n <= 1: return 1 # otherwise it will melt the cpu + ''', (5, 4, 2, 1, 3, 0, 0)), +] + -@pytest.mark.parametrize('code,expected', ANALYZE_CASES) +@pytest.mark.parametrize('code,expected', MAIN_CASES + VISITOR_CASES) def test_analyze(code, expected): code = dedent(code) diff --git a/radon/tests/test_raw_visitor.py b/radon/tests/test_raw_visitor.py new file mode 100644 index 0000000..307004c --- /dev/null +++ b/radon/tests/test_raw_visitor.py @@ -0,0 +1,44 @@ +import sys + +import pytest + +from radon.raw import Module +from radon.tests import test_raw + +# we expect an ImportError when python <3.8 +try: + from radon.raw_visitor import RawVisitor + + IMPORT_ERROR = False +except ImportError: + IMPORT_ERROR = True + +min_py_version = pytest.mark.xfail( + IMPORT_ERROR and sys.version_info < (3, 8), + reason="raw_visitor requires python >=3.8", +) + + +@min_py_version +@pytest.mark.parametrize("code, expected", test_raw.VISITOR_CASES) +def test_raw_visitor_functions(code, expected): + code = test_raw.dedent(code) + raw_visitor = RawVisitor.from_code(code) + # only one function in these tests + raw_result = raw_visitor.functions[0] + # exclude the details about function name, lineno, etc. for now + formated_result = Module(*raw_result[7:]) + assert formated_result == Module( + *expected + ), f"\ + \n input code: {code}\ + \n result: {formated_result} \ + \n expected: {Module(*expected)}" + + expected_loc = ( + formated_result.blank + + formated_result.sloc + + formated_result.single_comments + + formated_result.multi + ) + assert formated_result.loc == expected_loc diff --git a/requirements.txt b/requirements.txt index 92da0cd..b7ff29f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ mando>=0.6,<0.7 colorama==0.4.1 flake8_polyfill future +astunparse diff --git a/setup.py b/setup.py index babd49c..c4ce706 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,7 @@ 'colorama==0.4.1', 'flake8-polyfill', 'future', + 'astunparse', ], entry_points={ 'console_scripts': ['radon = radon:main'],