Repository: rubik/radon

Issue:
Halstead complexity not being calculated for async functions
When using Radon's CLI for the Halstead measures with the `-f` flag, they aren't being calculated for asynchronous functions. On the other hand, it is working for cyclomatic complexity.

Here's a code snippet for testing:
```python
async def foo():
    pass

def bar():
    pass
```

This is Radon's output for `radon hal -f test.py`:
```
test.py:
    bar:
        h1: 0
        h2: 0
        N1: 0
        N2: 0
        vocabulary: 0
        length: 0
        calculated_length: 0
        volume: 0
        difficulty: 0
        effort: 0
        time: 0.0
        bugs: 0.0
```

This is Radon's output for `radon cc test.py`:
```
test.py
    F 1:0 foo - A
    F 4:0 bar - A
```

I am using Python 3.8, with Radon 4.1.0 on Arch Linux. I have installed Radon from [the AUR](https://aur.archlinux.org/packages/python-radon/).

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/36009331/badge)](https://coveralls.io/builds/36009331)

Coverage decreased (-5.3%) to 84.278% when pulling **02ed5ce403be0b4fe65fb74f574d49aa60ee30b2 on rharish101:async** into **0a6aff726f29abfe1547b27446f3b2d78d2d77e6 on rubik:master**.


Patch:
diff --git a/CHANGELOG b/CHANGELOG
index 717e5a0..08ba4b8 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,9 @@
+4.4.0 (Mar 07, 2021)
+--------------------
+
+- Use utf-8 as the default encoding on Python 3.0+: #203
+- Allow custom config file path through the RADONCFG env var: #207
+
 4.3.2 (Sep 18, 2020)
 --------------------
 
diff --git a/Makefile b/Makefile
index 842482f..4ccdd10 100644
--- a/Makefile
+++ b/Makefile
@@ -35,7 +35,7 @@ docs:
 	cd docs && make html
 
 test-deps:
-	pip install -r test_requirements.pip
+	pip install -r test_requirements.txt
 
 publish:
 	rm -rf dist/*
diff --git a/docs/commandline.rst b/docs/commandline.rst
index 4a2e85d..8d57fc4 100644
--- a/docs/commandline.rst
+++ b/docs/commandline.rst
@@ -158,6 +158,11 @@ Options
    If given, the results will be converted into XML. Note that not all the
    information is kept. This is specifically targeted to Jenkin's plugin CCM.
 
+.. option:: --md
+
+   If given, the results will be converted into Markdown. Note that not all the
+   information is kept.
+
 .. option:: --no-assert
 
    Does not count assert statements when computing complexity. This is because
diff --git a/radon/__init__.py b/radon/__init__.py
index 6ba288c..5369363 100644
--- a/radon/__init__.py
+++ b/radon/__init__.py
@@ -1,7 +1,7 @@
 '''This module contains the main() function, which is the entry point for the
 command line interface.'''
 
-__version__ = '4.3.2'
+__version__ = '4.4.0'
 
 
 def main():
diff --git a/radon/cli/__init__.py b/radon/cli/__init__.py
index c4dc62d..3aa3395 100644
--- a/radon/cli/__init__.py
+++ b/radon/cli/__init__.py
@@ -53,8 +53,9 @@ class FileConfig(object):
     def file_config():
         '''Return any file configuration discovered'''
         config = configparser.ConfigParser()
-        if os.path.exists('radon.cfg'):
-            config.read_file(open('radon.cfg'))
+        for path in (os.getenv('RADONCFG', None), 'radon.cfg'):
+            if path is not None and os.path.exists(path):
+                config.read_file(open(path))
         config.read(['setup.cfg', os.path.expanduser('~/.radon.cfg')])
         return config
 
@@ -80,6 +81,7 @@ def cc(
     show_closures=_cfg.get_value('show_closures', bool, False),
     total_average=_cfg.get_value('total_average', bool, False),
     xml=False,
+    md=False,
     codeclimate=False,
     output_file=_cfg.get_value('output_file', str, None),
     include_ipynb=_cfg.get_value('include_ipynb', bool, False),
@@ -111,6 +113,7 @@ def cc(
         ALPHA.
     :param -j, --json: Format results in JSON.
     :param --xml: Format results in XML (compatible with CCM).
+    :param --md: Format results in Markdown.
     :param --codeclimate: Format results for Code Climate.
     :param --no-assert: Do not count `assert` statements when computing
         complexity.
@@ -139,6 +142,7 @@ def cc(
             harvester,
             json=json,
             xml=xml,
+            md=md,
             codeclimate=codeclimate,
             stream=stream,
         )
@@ -284,7 +288,7 @@ def hal(
 
     harvester = HCHarvester(paths, config)
     with outstream(output_file) as stream:
-        log_result(harvester, json=json, xml=False, stream=stream)
+        log_result(harvester, json=json, xml=False, md=False, stream=stream)
 
 
 class Config(object):
@@ -348,6 +352,8 @@ def log_result(harvester, **kwargs):
             noformat=True,
             **kwargs
         )
+    elif kwargs.get('md'):
+        log(harvester.as_md(), noformat=True, **kwargs)
     else:
         for msg, h_args, h_kwargs in harvester.to_terminal():
             kw = kwargs.copy()
diff --git a/radon/cli/harvest.py b/radon/cli/harvest.py
index fa20b59..d81570c 100644
--- a/radon/cli/harvest.py
+++ b/radon/cli/harvest.py
@@ -12,6 +12,7 @@ from radon.cli.tools import (
     cc_to_terminal,
     dict_to_codeclimate_issues,
     dict_to_xml,
+    dict_to_md,
     iter_filenames,
     raw_to_dict,
     strip_ipython,
@@ -154,6 +155,10 @@ class Harvester(object):
         '''Format the results as XML.'''
         raise NotImplementedError
 
+    def as_md(self):
+        '''Format the results as Markdown.'''
+        raise NotImplementedError
+
     def as_codeclimate_issues(self):
         '''Format the results as Code Climate issues.'''
         raise NotImplementedError
@@ -203,6 +208,10 @@ class CCHarvester(Harvester):
         '''
         return dict_to_xml(self._to_dicts())
 
+    def as_md(self):
+        '''Format the results as Markdown.'''
+        return dict_to_md(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)
diff --git a/radon/cli/tools.py b/radon/cli/tools.py
index e4b52e1..8fda7fd 100644
--- a/radon/cli/tools.py
+++ b/radon/cli/tools.py
@@ -15,13 +15,8 @@ import sys
 import xml.etree.cElementTree as et
 from contextlib import contextmanager
 
-from radon.cli.colors import (
-    BRIGHT,
-    LETTERS_COLORS,
-    RANKS_COLORS,
-    RESET,
-    TEMPLATE,
-)
+from radon.cli.colors import (BRIGHT, LETTERS_COLORS, RANKS_COLORS, RESET,
+                              TEMPLATE)
 from radon.complexity import cc_rank
 from radon.visitors import Function
 
@@ -49,13 +44,17 @@ if platform.python_implementation() == 'PyPy':
 
 
 else:
+    if sys.version_info[:2] >= (3, 0):
+        default_encoding = 'utf-8'
+    else:
+        default_encoding = locale.getpreferredencoding(False)
     # Add customized file encoding to fix #86.
     # By default `open()` function uses `locale.getpreferredencoding(False)`
     # encoding (see https://docs.python.org/3/library/functions.html#open).
     # This code allows to change `open()` encoding by setting an environment
     # variable.
     _encoding = os.getenv(
-        'RADONFILESENCODING', locale.getpreferredencoding(False)
+        'RADONFILESENCODING', default_encoding
     )
 
     if sys.version_info[:2] < (2, 7):
@@ -66,8 +65,9 @@ else:
         # is significant.
         from codecs import open as _open_function
     elif sys.version_info[:2] < (3, 0):
-        from io import open as _io_open_function, TextIOWrapper
-        from codecs import lookup, BOM_UTF8
+        from codecs import BOM_UTF8, lookup
+        from io import TextIOWrapper
+        from io import open as _io_open_function
 
         cookie_re = re.compile(r'^[ \t\f]*#.*?coding[:=][ \t]*([-\w.]+)')
         blank_re = re.compile(r'^[ \t\f]*(?:[#\r\n]|$)')
@@ -350,6 +350,36 @@ def dict_to_xml(results):
     return et.tostring(ccm).decode('utf-8')
 
 
+def dict_to_md(results):
+    md_string = '''
+| Filename | Name | Type | Start:End Line | Complexity | Clasification |
+| -------- | ---- | ---- | -------------- | ---------- | ------------- |
+'''
+    for filename, blocks in results.items():
+        for block in blocks:
+            raw_classname = block.get("classname")
+            raw_name = block.get("name")
+            name = "{}.{}".format(
+                raw_classname,
+                raw_name) if raw_classname else block["name"]
+            raw_is_method = block.get("is_method")
+            if raw_classname and raw_is_method:
+                type = "M"
+            elif raw_is_method is None:
+                type = "C"
+            else:
+                type = "F"
+            md_string += "| {} | {} | {} | {}:{} | {} | {} |\n".format(
+                filename,
+                name,
+                type,
+                block["lineno"],
+                block["endline"],
+                block["complexity"],
+                block["rank"])
+    return md_string
+
+
 def dict_to_codeclimate_issues(results, threshold='B'):
     '''Convert a dictionary holding CC analysis results into Code Climate
      issue json.'''
diff --git a/radon/visitors.py b/radon/visitors.py
index db124c2..1be86b3 100644
--- a/radon/visitors.py
+++ b/radon/visitors.py
@@ -445,3 +445,9 @@ class HalsteadVisitor(CodeVisitor):
 
         # Save the visited function visitor for later reference.
         self.function_visitors.append(func_visitor)
+
+    def visit_AsyncFunctionDef(self, node):
+        '''Async functions are similar to standard functions, so treat them as
+        such.
+        '''
+        self.visit_FunctionDef(node)


Test patch:
diff --git a/radon/tests/test_cli.py b/radon/tests/test_cli.py
index 918893f..8b19372 100644
--- a/radon/tests/test_cli.py
+++ b/radon/tests/test_cli.py
@@ -115,6 +115,7 @@ def test_cc(mocker, log_mock):
         json=True,
         stream=sys.stdout,
         xml=False,
+        md=False
     )
 
 
@@ -243,19 +244,24 @@ def test_log_result(mocker, stdout_mock):
     h = mocker.Mock(spec=Harvester)
     h.as_json.return_value = mocker.sentinel.json
     h.as_xml.return_value = mocker.sentinel.xml
+    h.as_md.return_value = mocker.sentinel.md
     h.to_terminal.side_effect = fake_to_terminal
 
     cli.log_result(h, json=True)
     h.as_json.assert_called_once_with()
 
     h.as_json.reset_mock()
-    cli.log_result(h, json=True, xml=True)
+    cli.log_result(h, json=True, xml=True, md=True)
     h.as_json.assert_called_once_with()
     assert h.as_xml.call_count == 0
+    assert h.as_md.call_count == 0
 
     cli.log_result(h, xml=True)
     h.as_xml.assert_called_once_with()
 
+    cli.log_result(h, md=True)
+    h.as_md.assert_called_once_with()
+
     cli.log_result(h)
     h.to_terminal.assert_called_once_with()
 
@@ -263,9 +269,10 @@ def test_log_result(mocker, stdout_mock):
         [
             mocker.call(mocker.sentinel.json, json=True, noformat=True),
             mocker.call(
-                mocker.sentinel.json, json=True, noformat=True, xml=True
+                mocker.sentinel.json, json=True, noformat=True, xml=True, md=True
             ),
             mocker.call(mocker.sentinel.xml, noformat=True, xml=True),
+            mocker.call(mocker.sentinel.md, noformat=True, md=True),
             mocker.call('a', error=True),
         ]
     )
diff --git a/radon/tests/test_cli_harvest.py b/radon/tests/test_cli_harvest.py
index 0bbf869..a7b451f 100644
--- a/radon/tests/test_cli_harvest.py
+++ b/radon/tests/test_cli_harvest.py
@@ -86,6 +86,12 @@ def test_base_as_xml_not_implemented(base_config):
         h.as_xml()
 
 
+def test_base_as_md_not_implemented(base_config):
+    h = harvest.Harvester([], base_config)
+    with pytest.raises(NotImplementedError):
+        h.as_md()
+
+
 def test_base_to_terminal_not_implemented(base_config):
     h = harvest.Harvester([], base_config)
     with pytest.raises(NotImplementedError):
@@ -170,6 +176,19 @@ def test_cc_as_json_xml(cc_config, mocker):
     assert to_dicts_mock.call_count == 2
 
 
+def test_cc_as_md(cc_config, mocker):
+    d2md_mock = mocker.patch('radon.cli.harvest.dict_to_md')
+    to_dicts_mock = mocker.MagicMock()
+    to_dicts_mock.return_value = {'a': {'rank': 'A'}}
+
+    h = harvest.CCHarvester([], cc_config)
+    h._to_dicts = to_dicts_mock
+    assert h.as_md()
+    assert d2md_mock.called
+    d2md_mock.assert_called_with(to_dicts_mock.return_value)
+    assert to_dicts_mock.call_count == 1
+
+
 def test_cc_to_terminal(cc_config, mocker):
     reset_mock = mocker.patch('radon.cli.harvest.RESET')
     ranks_mock = mocker.patch('radon.cli.harvest.RANKS_COLORS')
diff --git a/radon/tests/test_cli_tools.py b/radon/tests/test_cli_tools.py
index 86d95d9..ca7cca7 100644
--- a/radon/tests/test_cli_tools.py
+++ b/radon/tests/test_cli_tools.py
@@ -56,8 +56,12 @@ def test_open(mocker):
     else:
         mocker.patch('radon.cli.tools._open_function', m, create=True)
         tools._open('randomfile.py').__enter__()
+        if sys.version_info[:2] >= (3, 0):
+            default_encoding = 'utf-8'
+        else:
+            default_encoding = locale.getpreferredencoding(False)
         except_encoding = os.getenv(
-            'RADONFILESENCODING', locale.getpreferredencoding(False)
+            'RADONFILESENCODING', default_encoding
         )
         m.assert_called_with('randomfile.py', encoding=except_encoding)
 
@@ -375,6 +379,21 @@ def test_cc_to_xml():
     )
 
 
+def test_cc_to_md():
+    assert (
+        tools.dict_to_md({'filename': CC_TO_XML_CASE})
+        == '''
+| Filename | Name | Type | Start:End Line | Complexity | Clasification |
+| -------- | ---- | ---- | -------------- | ---------- | ------------- |
+| filename | name | F | 12:16 | 6 | B |
+| filename | Classname | C | 17:29 | 8 | B |
+| filename | Classname.name | M | 19:26 | 7 | B |
+| filename | aux | F | 13:17 | 4 | A |
+| filename | name | F | 12:16 | 10 | B |
+'''
+    )
+
+
 def test_cc_error_to_codeclimate():
     error_result = {'error': 'Error: invalid syntax (<unknown>, line 100)'}
 
diff --git a/radon/tests/test_halstead.py b/radon/tests/test_halstead.py
index 4d4466d..ea95292 100644
--- a/radon/tests/test_halstead.py
+++ b/radon/tests/test_halstead.py
@@ -1,3 +1,4 @@
+import sys
 import textwrap
 
 import pytest
@@ -84,6 +85,22 @@ SIMPLE_BLOCKS = [
     ),
 ]
 
+if sys.version_info[:2] >= (3, 5):
+    SIMPLE_BLOCKS.append(
+        (
+            '''
+        a = 2
+        b = 3
+        a *= b
+
+        async def f():
+            b = 2
+            b += 4
+        ''',
+            (2, 4, 2, 4),
+        ),
+    )
+
 
 @pytest.mark.parametrize('code,expected', SIMPLE_BLOCKS)
 def test_visitor(code, expected):