diff --git a/docs/commandline.rst b/docs/commandline.rst index 44776ed..4db98a6 100644 --- a/docs/commandline.rst +++ b/docs/commandline.rst @@ -444,7 +444,8 @@ The :command:`hal` command This command analyzes Python source files and computes their Halstead complexity metrics. Files can be analyzed as wholes, or in terms of their -top-level functions with the :option:`-f` flag. +top-level functions with the :option:`-f` flag. Method names can be +prefixed with their class names if the :option:`-c` flag is used. Excluding files or directories is supported through glob patterns with the :option:`-e` flag. Every positional argument is interpreted as a path. The @@ -496,6 +497,13 @@ Options Value can be set in a configuration file using the ``ipynb_cells`` property. +.. option:: -c, --class_names + + When showing metrics on the function level, prefix method names with their + class names. + + Value can be set in a configuration file using the ``class_names`` property. + Examples ++++++++ diff --git a/pyproject.toml b/pyproject.toml index c25f1bd..9dfe9fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ colorama = [ {version = ">=0.4.1", markers = "python_version > \"3.4\""}, {version = "==0.4.1", markers = "python_version <= \"3.4\""} ] +asttokens = "*" [tool.poetry.group.dev.dependencies] coverage = "*" diff --git a/radon/cli/__init__.py b/radon/cli/__init__.py index 3af5138..b79f2fb 100644 --- a/radon/cli/__init__.py +++ b/radon/cli/__init__.py @@ -186,6 +186,7 @@ def raw( output_file=_cfg.get_value('output_file', str, None), include_ipynb=_cfg.get_value('include_ipynb', bool, False), ipynb_cells=_cfg.get_value('ipynb_cells', bool, False), + detailed=_cfg.get_value('detailed', bool, False), ): '''Analyze the given Python modules and compute raw metrics. @@ -203,6 +204,7 @@ def raw( :param -O, --output-file : The output file (default to stdout). :param --include-ipynb: Include IPython Notebook files :param --ipynb-cells: Include reports for individual IPYNB cells + :param -d, --detailed: Display metrics for functions, classes and methods. ''' config = Config( exclude=exclude, @@ -210,6 +212,7 @@ def raw( summary=summary, include_ipynb=include_ipynb, ipynb_cells=ipynb_cells, + detailed=detailed, ) harvester = RawHarvester(paths, config) with outstream(output_file) as stream: @@ -284,6 +287,7 @@ def hal( output_file=_cfg.get_value('output_file', str, None), include_ipynb=_cfg.get_value('include_ipynb', bool, False), ipynb_cells=_cfg.get_value('ipynb_cells', bool, False), + class_names=_cfg.get_value('class_names', bool, False), ): """ Analyze the given Python modules and compute their Halstead metrics. @@ -305,6 +309,8 @@ def hal( :param -O, --output-file : The output file (default to stdout). :param --include-ipynb: Include IPython Notebook files :param --ipynb-cells: Include reports for individual IPYNB cells + :param -c, --class-names: Include class names before method names as + class.method. """ config = Config( exclude=exclude, @@ -312,6 +318,7 @@ def hal( by_function=functions, include_ipynb=include_ipynb, ipynb_cells=ipynb_cells, + class_names=class_names, ) harvester = HCHarvester(paths, config) diff --git a/radon/cli/harvest.py b/radon/cli/harvest.py index ac14fe5..f1e88ed 100644 --- a/radon/cli/harvest.py +++ b/radon/cli/harvest.py @@ -24,7 +24,8 @@ sorted_results, ) from radon.metrics import h_visit, mi_rank, mi_visit -from radon.raw import analyze +from radon.raw import Module, analyze +from radon.raw_visitor import RawClassMetrics, RawFunctionMetrics, RawVisitor if sys.version_info[0] < 3: from StringIO import StringIO @@ -267,7 +268,7 @@ class RawHarvester(Harvester): def gobble(self, fobj): '''Analyze the content of the file object.''' - return raw_to_dict(analyze(fobj.read())) + return RawVisitor.from_code(fobj.read()).blocks def as_xml(self): '''Placeholder method. Currently not implemented.''' @@ -276,33 +277,44 @@ def as_xml(self): def to_terminal(self): '''Yield lines to be printed to a terminal.''' sum_metrics = collections.defaultdict(int) - for path, mod in self.results: - if 'error' in mod: - yield path, (mod['error'],), {'error': True} - continue - yield path, (), {} - for header in self.headers: - value = mod[header.lower().replace(' ', '_')] - yield '{0}: {1}', (header, value), {'indent': 1} - sum_metrics[header] += value - - loc, comments = mod['loc'], mod['comments'] - yield '- Comment Stats', (), {'indent': 1} - yield ( - '(C % L): {0:.0%}', - (comments / (float(loc) or 1),), - {'indent': 2}, - ) - yield ( - '(C % S): {0:.0%}', - (comments / (float(mod['sloc']) or 1),), - {'indent': 2}, - ) - yield ( - '(C + M % L): {0:.0%}', - ((comments + mod['multi']) / (float(loc) or 1),), - {'indent': 2}, - ) + for path, mods in self.results: + for name, result in mods: + if isinstance(result, (Module, RawClassMetrics, RawFunctionMetrics)): + mod = raw_to_dict(result) + else: + mod = result + if 'error' in mod: + yield name, (mod['error'],), {'error': True} + continue + if name == "__ModuleMetrics__": + yield path, (), {} + elif not hasattr(self.config, "detailed") or not self.config.detailed: + continue + else: + yield f"{path}:{name}", (), {} + for header in self.headers: + value = mod[header.lower().replace(' ', '_')] + yield '{0}: {1}', (header, value), {'indent': 1} + if name == "__ModuleMetrics__": + sum_metrics[header] += value + + loc, comments = mod['loc'], mod['comments'] + yield '- Comment Stats', (), {'indent': 1} + yield ( + '(C % L): {0:.0%}', + (comments / (float(loc) or 1),), + {'indent': 2}, + ) + yield ( + '(C % S): {0:.0%}', + (comments / (float(mod['sloc']) or 1),), + {'indent': 2}, + ) + yield ( + '(C + M % L): {0:.0%}', + ((comments + mod['multi']) / (float(loc) or 1),), + {'indent': 2}, + ) if self.config.summary: _get = lambda k, v=0: sum_metrics.get(k, v) @@ -384,11 +396,12 @@ class HCHarvester(Harvester): def __init__(self, paths, config): super().__init__(paths, config) self.by_function = config.by_function + self.class_names = getattr(config, "class_names", False) def gobble(self, fobj): """Analyze the content of the file object.""" code = fobj.read() - return h_visit(code) + return h_visit(code, self.class_names) def as_json(self): """Format the results as JSON.""" diff --git a/radon/metrics.py b/radon/metrics.py index 7c6ec63..b0993eb 100644 --- a/radon/metrics.py +++ b/radon/metrics.py @@ -22,14 +22,14 @@ Halstead = collections.namedtuple("Halstead", "total functions") -def h_visit(code): +def h_visit(code, class_names=False): '''Compile the code into an AST tree and then pass it to :func:`~radon.metrics.h_visit_ast`. ''' - return h_visit_ast(ast.parse(code)) + return h_visit_ast(ast.parse(code), class_names) -def h_visit_ast(ast_node): +def h_visit_ast(ast_node, class_names=False): ''' Visit the AST node using the :class:`~radon.visitors.HalsteadVisitor` visitor. The results are `HalsteadReport` namedtuples with the following @@ -56,7 +56,7 @@ def h_visit_ast(ast_node): Nested functions are not tracked. ''' - visitor = HalsteadVisitor.from_ast(ast_node) + visitor = HalsteadVisitor.from_ast(ast_node, class_names=class_names) total = halstead_visitor_report(visitor) functions = [ (v.context, halstead_visitor_report(v)) diff --git a/radon/raw_visitor.py b/radon/raw_visitor.py new file mode 100644 index 0000000..e51ca3b --- /dev/null +++ b/radon/raw_visitor.py @@ -0,0 +1,318 @@ +"""Visitor for raw metrics.""" + +import ast +import tokenize +from collections import namedtuple + +import asttokens + +from radon.metrics import analyze +from radon.raw import Module +from radon.visitors import CodeVisitor, GET_ENDLINE + +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 representing 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 RawVisitor(CodeVisitor): + """A visitor that keeps track of raw metrics for block of code. + + Metrics are provided for modules, 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. + """ + + def __init__(self, to_method=False, classname=None, atok=None): + self.functions = [] + self.classes = [] + self.to_method = to_method + self.classname = classname + self._max_line = float("-inf") + self.atok = atok + + @classmethod + def from_code(cls, code, **kwargs): + """Instantiate the class from source code (string object).""" + cls.code = code + node = asttokens.ASTTokens(code, parse=True).tree + return cls.from_ast(node, **kwargs) + + @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 = [("__ModuleMetrics__", self.module)] + blocks.extend((f.fullname, f) for f in self.functions) + for cls in self.classes: + blocks.append((cls.name, cls)) + blocks.extend((m.fullname, m) for m in 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, module=False): + if self.atok is None: + self.atok = asttokens.ASTTokens(self.code, parse=True) + source_segment = self.atok.get_text(node, False) + source_segment += get_trailing_comments(self.atok, node, source_segment) + # print(ast.dump(node)) + # print("Original:\n", self.code) + # print("\nUnparsed:\n", source_segment, "\n") + assert source_segment in self.code + if not module: + source_segment = source_segment.strip() + raw_metrics = analyze(source_segment) + self.loc = raw_metrics.loc + self.lloc = raw_metrics.lloc + self.sloc = raw_metrics.sloc + self.comments = raw_metrics.comments + self.multi = raw_metrics.multi + self.blank = raw_metrics.blank + self.single_comments = raw_metrics.single_comments + + def visit_FunctionDef(self, node): + """When visiting functions a new visitor is created to recursively + analyze the function's body. + """ + print(node.name) + closures = [] + visitor = None + + # Do we really need closures for Raw? + for child in node.body: + visitor = RawVisitor(classname=self.classname, atok=self.atok) + visitor.visit(child) + closures.extend(visitor.functions) + + max_line = visitor.max_line if visitor is not None else 0 + self.get_raw_metrics(node) + func_metrics = RawFunctionMetrics( + node.name, + node.lineno, + node.col_offset, + max(node.lineno, max_line, node.lineno + self.loc - 1), + 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: + if not isinstance(child, (ast.ClassDef, ast.FunctionDef)): + continue + visitor = RawVisitor( + True, + classname, + atok=self.atok, + ) + visitor.visit(child) + methods.extend(visitor.functions) + visitors_max_lines.append(visitor.max_line) + inner_classes.extend(visitor.classes) + + self.get_raw_metrics(node) + line_loc = [node.lineno + self.loc - 1] + cls_metrics = RawClassMetrics( + classname, + node.lineno, + node.col_offset, + max(visitors_max_lines + list(map(GET_ENDLINE, methods)) + line_loc), + methods, + inner_classes, + self.loc, + self.lloc, + self.sloc, + self.comments, + self.multi, + self.blank, + self.single_comments, + ) + self.classes.append(cls_metrics) + + def visit_Module(self, node): + if self.atok is None: + self.atok = asttokens.ASTTokens(self.code, parse=True) + + for child in node.body: + visitor = RawVisitor(atok=self.atok) + visitor.visit(child) + self.classes.extend(visitor.classes) + self.functions.extend(visitor.functions) + + self.get_raw_metrics(node, module=True) + self.module = Module( + self.loc, + self.lloc, + self.sloc, + self.comments, + self.multi, + self.blank, + self.single_comments, + ) + + +def get_trailing_comments(atok, node, source_segment): + # Get any trailing comments + first = next(atok.get_tokens(node, True)) + indent = 0 + for c in first.line: + if c != " ": + break + indent += 1 + token = list(atok.get_tokens(node, True))[-1] + comments_and_newlines = (tokenize.COMMENT, tokenize.NL, tokenize.NEWLINE) + while token.type != tokenize.ENDMARKER: + try: + next_token = atok.next_token(token, include_extra=True) + # Stop processing if we find something that isn't a comment/newline + if next_token.type not in comments_and_newlines: + break + # Stop if the comment found is less indented than the node's first line + elif "#" in next_token.line and next_token.line.index("#") < indent: + break + token = next_token + except IndexError: + break + comment = token + len_source = len(source_segment) + trailing = "" + # Recover content of trailing comments if any + if len_source <= comment.startpos: + # Expects that the source segment is unique in the code + index = atok.text.index(source_segment) + trailing = atok.text[index + len_source : comment.endpos] + return trailing diff --git a/radon/tests/test_cli.py b/radon/tests/test_cli.py index 8b19372..9019db3 100644 --- a/radon/tests/test_cli.py +++ b/radon/tests/test_cli.py @@ -119,6 +119,32 @@ def test_cc(mocker, log_mock): ) +def test_hal(mocker, log_mock): + harv_mock = mocker.patch('radon.cli.HCHarvester') + harv_mock.return_value = mocker.sentinel.harvester + + cli.hal(['-'], class_names=True) + + harv_mock.assert_called_once_with( + ['-'], + cli.Config( + exclude=None, + ignore=None, + by_function=False, + include_ipynb=False, + ipynb_cells=False, + class_names=True, + ), + ) + log_mock.assert_called_once_with( + mocker.sentinel.harvester, + json=False, + stream=sys.stdout, + xml=False, + md=False + ) + + def test_raw(mocker, log_mock): harv_mock = mocker.patch('radon.cli.RawHarvester') harv_mock.return_value = mocker.sentinel.harvester @@ -133,6 +159,7 @@ def test_raw(mocker, log_mock): summary=True, include_ipynb=False, ipynb_cells=False, + detailed=False, ), ) log_mock.assert_called_once_with( diff --git a/radon/tests/test_cli_harvest.py b/radon/tests/test_cli_harvest.py index a7b451f..aae791cf 100644 --- a/radon/tests/test_cli_harvest.py +++ b/radon/tests/test_cli_harvest.py @@ -8,6 +8,7 @@ import radon.cli.harvest as harvest import radon.complexity as cc_mod from radon.cli import Config +from radon import raw_visitor BASE_CONFIG = Config( exclude=r'test_[^.]+\.py', @@ -28,7 +29,7 @@ **BASE_CONFIG.config_values ) -RAW_CONFIG = Config(summary=True,) +RAW_CONFIG = Config(summary=True, exclude='', ignore='', detailed=True) MI_CONFIG = Config(multi=True, min='B', max='C', show=True, sort=False,) @@ -221,18 +222,18 @@ def test_cc_to_terminal(cc_config, mocker): def test_raw_gobble(raw_config, mocker): - r2d_mock = mocker.patch('radon.cli.harvest.raw_to_dict') - analyze_mock = mocker.patch('radon.cli.harvest.analyze') + analyze_mock = mocker.patch('radon.raw_visitor.analyze') fobj = mocker.MagicMock() - fobj.read.return_value = mocker.sentinel.one - analyze_mock.return_value = mocker.sentinel.two + fobj.read.return_value = "sentinel" + dict_mock = mocker.MagicMock() + dict_mock.__getitem__.return_value = "" + analyze_mock.return_value = dict_mock h = harvest.RawHarvester([], raw_config) h.gobble(fobj) assert fobj.read.call_count == 1 - analyze_mock.assert_called_once_with(mocker.sentinel.one) - r2d_mock.assert_called_once_with(mocker.sentinel.two) + analyze_mock.assert_called_once_with("sentinel") def test_raw_as_xml(raw_config): @@ -241,10 +242,21 @@ def test_raw_as_xml(raw_config): h.as_xml() -def test_raw_to_terminal(raw_config): +def test_raw_to_terminal_from_dict(raw_config): h = harvest.RawHarvester([], raw_config) - h._results = [ - ('a', {'error': 'mystr'}), + h._results = (["path", [ + ( + '__ModuleMetrics__', + { + 'loc': 48, + 'lloc': 54, + 'sloc': 30, + 'comments': 6, + 'multi': 6, + 'single_comments': 28, + 'blank': 18, + }, + ), ( 'b', { @@ -269,6 +281,7 @@ def test_raw_to_terminal(raw_config): 'blank': 9, }, ), + ('d', {'error': 'mystr'}), ( 'e', { @@ -281,11 +294,180 @@ def test_raw_to_terminal(raw_config): 'blank': 0, }, ), + ]], + ) + + assert list(h.to_terminal()) == [ + ('path', (), {}), + ('{0}: {1}', ('LOC', 48), {'indent': 1}), + ('{0}: {1}', ('LLOC', 54), {'indent': 1}), + ('{0}: {1}', ('SLOC', 30), {'indent': 1}), + ('{0}: {1}', ('Comments', 6), {'indent': 1}), + ('{0}: {1}', ('Single comments', 28), {'indent': 1}), + ('{0}: {1}', ('Multi', 6), {'indent': 1}), + ('{0}: {1}', ('Blank', 18), {'indent': 1}), + ('- Comment Stats', (), {'indent': 1}), + ('(C % L): {0:.0%}', (0.125,), {'indent': 2}), + ('(C % S): {0:.0%}', (0.2,), {'indent': 2}), + ('(C + M % L): {0:.0%}', (0.25,), {'indent': 2}), + ('path:b', (), {}), + ('{0}: {1}', ('LOC', 24), {'indent': 1}), + ('{0}: {1}', ('LLOC', 27), {'indent': 1}), + ('{0}: {1}', ('SLOC', 15), {'indent': 1}), + ('{0}: {1}', ('Comments', 3), {'indent': 1}), + ('{0}: {1}', ('Single comments', 3), {'indent': 1}), + ('{0}: {1}', ('Multi', 3), {'indent': 1}), + ('{0}: {1}', ('Blank', 9), {'indent': 1}), + ('- Comment Stats', (), {'indent': 1}), + ('(C % L): {0:.0%}', (0.125,), {'indent': 2}), + ('(C % S): {0:.0%}', (0.2,), {'indent': 2}), + ('(C + M % L): {0:.0%}', (0.25,), {'indent': 2}), + ('path:c', (), {}), + ('{0}: {1}', ('LOC', 24), {'indent': 1}), + ('{0}: {1}', ('LLOC', 27), {'indent': 1}), + ('{0}: {1}', ('SLOC', 15), {'indent': 1}), + ('{0}: {1}', ('Comments', 3), {'indent': 1}), + ('{0}: {1}', ('Single comments', 13), {'indent': 1}), + ('{0}: {1}', ('Multi', 3), {'indent': 1}), + ('{0}: {1}', ('Blank', 9), {'indent': 1}), + ('- Comment Stats', (), {'indent': 1}), + ('(C % L): {0:.0%}', (0.125,), {'indent': 2}), + ('(C % S): {0:.0%}', (0.2,), {'indent': 2}), + ('(C + M % L): {0:.0%}', (0.25,), {'indent': 2}), + ('d', ('mystr',), {'error': True}), + ('path:e', (), {}), + ('{0}: {1}', ('LOC', 0), {'indent': 1}), + ('{0}: {1}', ('LLOC', 0), {'indent': 1}), + ('{0}: {1}', ('SLOC', 0), {'indent': 1}), + ('{0}: {1}', ('Comments', 0), {'indent': 1}), + ('{0}: {1}', ('Single comments', 12), {'indent': 1}), + ('{0}: {1}', ('Multi', 0), {'indent': 1}), + ('{0}: {1}', ('Blank', 0), {'indent': 1}), + ('- Comment Stats', (), {'indent': 1}), + ('(C % L): {0:.0%}', (0.0,), {'indent': 2}), + ('(C % S): {0:.0%}', (0.0,), {'indent': 2}), + ('(C + M % L): {0:.0%}', (0.0,), {'indent': 2}), + ('** Total **', (), {}), + ('{0}: {1}', ('LOC', 48), {'indent': 1}), + ('{0}: {1}', ('LLOC', 54), {'indent': 1}), + ('{0}: {1}', ('SLOC', 30), {'indent': 1}), + ('{0}: {1}', ('Comments', 6), {'indent': 1}), + ('{0}: {1}', ('Single comments', 28), {'indent': 1}), + ('{0}: {1}', ('Multi', 6), {'indent': 1}), + ('{0}: {1}', ('Blank', 18), {'indent': 1}), + ('- Comment Stats', (), {'indent': 1}), + ('(C % L): {0:.0%}', (0.125,), {'indent': 2}), + ('(C % S): {0:.0%}', (0.2,), {'indent': 2}), + ('(C + M % L): {0:.0%}', (0.25,), {'indent': 2}), ] + +def test_raw_to_terminal_from_classes(raw_config): + h = harvest.RawHarvester([], raw_config) + h._results = (["path", [ + ( + '__ModuleMetrics__', + raw_visitor.Module( + 48, + 54, + 30, + 6, + 6, + 18, + 28, + ), + ), + ( + 'b', + raw_visitor.Module( + 24, + 27, + 15, + 3, + 3, + 9, + 3, + ), + ), + ( + 'c', + raw_visitor.Module( + 24, + 27, + 15, + 3, + 3, + 9, + 13, + ), + ), + ('d', {'error': 'mystr'}), + ( + 'e', + raw_visitor.Module( + 0, + 0, + 0, + 0, + 0, + 0, + 12, + ), + ), + ( + 'ClassName', + raw_visitor.RawClassMetrics( + "ClassName", + 1, + 0, + 10, + [], + [], + 0, + 0, + 0, + 0, + 0, + 0, + 12, + ), + ), + ( + 'function_name', + raw_visitor.RawFunctionMetrics( + "function_name", + 1, + 0, + 10, + False, + "", + [], + 0, + 0, + 0, + 0, + 0, + 0, + 12, + ), + ), + ]], + ) + assert list(h.to_terminal()) == [ - ('a', ('mystr',), {'error': True}), - ('b', (), {}), + ('path', (), {}), + ('{0}: {1}', ('LOC', 48), {'indent': 1}), + ('{0}: {1}', ('LLOC', 54), {'indent': 1}), + ('{0}: {1}', ('SLOC', 30), {'indent': 1}), + ('{0}: {1}', ('Comments', 6), {'indent': 1}), + ('{0}: {1}', ('Single comments', 28), {'indent': 1}), + ('{0}: {1}', ('Multi', 6), {'indent': 1}), + ('{0}: {1}', ('Blank', 18), {'indent': 1}), + ('- Comment Stats', (), {'indent': 1}), + ('(C % L): {0:.0%}', (0.125,), {'indent': 2}), + ('(C % S): {0:.0%}', (0.2,), {'indent': 2}), + ('(C + M % L): {0:.0%}', (0.25,), {'indent': 2}), + ('path:b', (), {}), ('{0}: {1}', ('LOC', 24), {'indent': 1}), ('{0}: {1}', ('LLOC', 27), {'indent': 1}), ('{0}: {1}', ('SLOC', 15), {'indent': 1}), @@ -297,7 +479,7 @@ def test_raw_to_terminal(raw_config): ('(C % L): {0:.0%}', (0.125,), {'indent': 2}), ('(C % S): {0:.0%}', (0.2,), {'indent': 2}), ('(C + M % L): {0:.0%}', (0.25,), {'indent': 2}), - ('c', (), {}), + ('path:c', (), {}), ('{0}: {1}', ('LOC', 24), {'indent': 1}), ('{0}: {1}', ('LLOC', 27), {'indent': 1}), ('{0}: {1}', ('SLOC', 15), {'indent': 1}), @@ -309,7 +491,32 @@ def test_raw_to_terminal(raw_config): ('(C % L): {0:.0%}', (0.125,), {'indent': 2}), ('(C % S): {0:.0%}', (0.2,), {'indent': 2}), ('(C + M % L): {0:.0%}', (0.25,), {'indent': 2}), - ('e', (), {}), + ('d', ('mystr',), {'error': True}), + ('path:e', (), {}), + ('{0}: {1}', ('LOC', 0), {'indent': 1}), + ('{0}: {1}', ('LLOC', 0), {'indent': 1}), + ('{0}: {1}', ('SLOC', 0), {'indent': 1}), + ('{0}: {1}', ('Comments', 0), {'indent': 1}), + ('{0}: {1}', ('Single comments', 12), {'indent': 1}), + ('{0}: {1}', ('Multi', 0), {'indent': 1}), + ('{0}: {1}', ('Blank', 0), {'indent': 1}), + ('- Comment Stats', (), {'indent': 1}), + ('(C % L): {0:.0%}', (0.0,), {'indent': 2}), + ('(C % S): {0:.0%}', (0.0,), {'indent': 2}), + ('(C + M % L): {0:.0%}', (0.0,), {'indent': 2}), + ('path:ClassName', (), {}), + ('{0}: {1}', ('LOC', 0), {'indent': 1}), + ('{0}: {1}', ('LLOC', 0), {'indent': 1}), + ('{0}: {1}', ('SLOC', 0), {'indent': 1}), + ('{0}: {1}', ('Comments', 0), {'indent': 1}), + ('{0}: {1}', ('Single comments', 12), {'indent': 1}), + ('{0}: {1}', ('Multi', 0), {'indent': 1}), + ('{0}: {1}', ('Blank', 0), {'indent': 1}), + ('- Comment Stats', (), {'indent': 1}), + ('(C % L): {0:.0%}', (0.0,), {'indent': 2}), + ('(C % S): {0:.0%}', (0.0,), {'indent': 2}), + ('(C + M % L): {0:.0%}', (0.0,), {'indent': 2}), + ('path:function_name', (), {}), ('{0}: {1}', ('LOC', 0), {'indent': 1}), ('{0}: {1}', ('LLOC', 0), {'indent': 1}), ('{0}: {1}', ('SLOC', 0), {'indent': 1}), diff --git a/radon/tests/test_raw.py b/radon/tests/test_raw.py index 4267a8f..cc1f1fb 100644 --- a/radon/tests/test_raw.py +++ b/radon/tests/test_raw.py @@ -189,7 +189,7 @@ def test_logical(code, expected_number_of_lines): assert _logical(code) == expected_number_of_lines -ANALYZE_CASES = [ +VISITOR_CASES = [ ( ''' ''', @@ -345,8 +345,9 @@ def foo(n=1): """ test = 0 # Comment + # Comment 2 ''', - (11, 6, 7, 2, 3, 0, 1), + (12, 6, 7, 3, 3, 0, 2), ), ( r''' @@ -443,10 +444,90 @@ def function(): ''', (2, 3, 2, 0, 0, 0, 0), ), + ( + r''' + class Test: + """ doc string """; pass + ''', + (2, 3, 2, 0, 0, 0, 0), + ), + ( + r''' + class Test: + """ doc string """ + def something(): + pass + #Comment + ''', + (5, 4, 3, 1, 0, 0, 2), + ), +] + +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..e455938 --- /dev/null +++ b/radon/tests/test_raw_visitor.py @@ -0,0 +1,119 @@ +import asttokens +import asttokens.util +import pytest + +import radon.raw_visitor +from radon.raw import Module +from radon.tests import test_raw + +EXTRA_CASES = [ + ( + r'''if 0: + def something(): + pass + #Comment +# Ignored comment + ''', + (3, 2, 2, 1, 0, 0, 1), # Expected values for the function node + ), +] + +CASES = test_raw.VISITOR_CASES + test_raw.MAIN_CASES + EXTRA_CASES + + +@pytest.mark.parametrize("code, expected", CASES) +def test_raw_visitor(code, expected): + code = test_raw.dedent(code) + try: + len(expected) + except: + with pytest.raises(expected): + radon.raw_visitor.RawVisitor.from_code(code) + else: + raw_visitor = radon.raw_visitor.RawVisitor.from_code(code) + # Handle only one function in these tests + if len(raw_visitor.functions) == 1: + raw_result = raw_visitor.functions[0] + # exclude the details about function name, lineno, etc. for now + formatted_result = Module(*raw_result[7:]) + elif len(raw_visitor.classes) == 1: + raw_result = raw_visitor.classes[0] + formatted_result = Module(*raw_result[6:]) + else: + formatted_result = raw_visitor.module + assert formatted_result == Module( + *expected + ), f"\ + \n input code: {code}\ + \n result: {formatted_result} \ + \n expected: {Module(*expected)}" + + expected_loc = ( + formatted_result.blank + + formatted_result.sloc + + formatted_result.single_comments + + formatted_result.multi + ) + assert formatted_result.loc == expected_loc + + +module = """ +import foo # Inline comment + + +class Something(foo.Nothing): + '''Class doc.''' + + def method(self, thing): + '''Method doc.''' + print(thing) + # Line comment + self.thing = thing + # Trailing comment + + +def function(parameter): + return parameter +""" + + +def test_raw_visitor_module(): + code = test_raw.dedent(module) + visitor = radon.raw_visitor.RawVisitor.from_code(code) + ast_visitor = radon.raw_visitor.RawVisitor.from_ast(asttokens.ASTTokens(code, parse=True).tree) + assert visitor.blocks == ast_visitor.blocks + first_block = visitor.blocks[0][1] + assert visitor.module == first_block + assert isinstance(first_block, Module) + for _name, block in visitor.blocks: + assert isinstance(block, (Module, radon.raw_visitor.RawClassMetrics, radon.raw_visitor.RawFunctionMetrics)) + blocks = dict(visitor.blocks) + assert "__ModuleMetrics__" in blocks + assert blocks["__ModuleMetrics__"] is first_block + assert blocks["__ModuleMetrics__"].loc == len(code.splitlines()) + assert blocks["__ModuleMetrics__"].comments == 3 + assert "Something" in blocks + assert blocks["Something"].methods[0].name == "method" + assert blocks["Something"].methods[0].classname == "Something" + assert blocks["Something"].methods[0].is_method is True + assert "Something.method" in blocks + assert blocks["Something"].methods[0] == blocks["Something.method"] + assert blocks["Something.method"].comments == 2 + assert blocks["Something.method"].single_comments == 3 + assert "function" in blocks + assert blocks["function"].name == "function" + assert blocks["function"].loc == 2 + assert blocks["function"].is_method is False + + +def test_get_trailing_comments(): + code = test_raw.dedent(module) + atok = asttokens.ASTTokens(code, parse=True) + for node in asttokens.util.walk(atok.tree): + if hasattr(node, "name") and node.name == "method": + break + segment = atok.get_text(node) + trailing = radon.raw_visitor.get_trailing_comments(atok, node, segment) + assert trailing not in segment + assert "# Trailing comment" in trailing + assert (segment + trailing) in code diff --git a/radon/visitors.py b/radon/visitors.py index e774648..e740e35 100644 --- a/radon/visitors.py +++ b/radon/visitors.py @@ -348,13 +348,15 @@ class HalsteadVisitor(CodeVisitor): "Constant": "value", } - def __init__(self, context=None): + def __init__(self, context=None, classname=None, class_names=False): '''*context* is a string used to keep track the analysis' context.''' self.operators_seen = set() self.operands_seen = set() self.operators = 0 self.operands = 0 self.context = context + self.classname = classname + self.class_names = class_names # A new visitor is spawned for every scanned function. self.function_visitors = [] @@ -436,10 +438,13 @@ def visit_FunctionDef(self, node): analyze the function's body. We also track information on the function itself. ''' - func_visitor = HalsteadVisitor(context=node.name) + name = node.name + if self.classname and self.class_names: + name = '{0}.{1}'.format(self.classname, node.name) + func_visitor = HalsteadVisitor(context=name, class_names=self.class_names) for child in node.body: - visitor = HalsteadVisitor.from_ast(child, context=node.name) + visitor = HalsteadVisitor.from_ast(child, context=name) self.operators += visitor.operators self.operands += visitor.operands self.operators_seen.update(visitor.operators_seen) @@ -458,3 +463,14 @@ def visit_AsyncFunctionDef(self, node): such. ''' self.visit_FunctionDef(node) + + def visit_ClassDef(self, node): + name = node.name if self.class_names else None + for child in node.body: + visitor = HalsteadVisitor(classname=name, class_names=self.class_names) + visitor.visit(child) + self.function_visitors.extend(visitor.function_visitors) + self.operators += visitor.operators + self.operands += visitor.operands + self.operators_seen.update(visitor.operators_seen) + self.operands_seen.update(visitor.operands_seen) diff --git a/requirements.txt b/requirements.txt index 0b75499..ee401f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ mando>=0.6,<0.8 colorama==0.4.1;python_version<='3.4' colorama>=0.4.1;python_version>'3.4' +asttokens \ No newline at end of file diff --git a/setup.py b/setup.py index 78468d4..027a67f 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,7 @@ 'mando>=0.6,<0.8', 'colorama==0.4.1;python_version<="3.4"', 'colorama>=0.4.1;python_version>"3.4"', + "asttokens", ], extras_require={ 'toml': ["tomli>=2.0.1"]