Repository: roskakori/pygount
Pull request #48 (2020-04-02 04:04:15)

#44 Cleaned up API and API documentation.
* Changed attributes in `SourceAnalysis` to read-only properties.
* Renamed properties holding counts from `xxx` to `xxx_count`.


Patch:
diff --git a/docs/api.rst b/docs/api.rst
index 62ca2f4..688f5d1 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -11,9 +11,45 @@ Here's an example on how to analyze one of pygount's own source codes:
 
 .. code-block:: pycon
 
-    >>> import pygount
-    >>> pygount.source_analysis('pygount/analysis.py', 'pygount')
-    SourceAnalysis(path='pygount/analysis.py', language='Python', group='pygount', state=analyzed, code=464, documentation=108, empty=104, string=22)
+    >>> from pygount import SourceAnalysis
+    >>> SourceAnalysis.from_file("pygount/analysis.py", "pygount")
+    SourceAnalysis(path='pygount/analysis.py', language='Python', group='pygount', state=analyzed, code_count=509, documentation_count=141, empty_count=117, string_count=23)
+
+Information about multiple source files can be summarize using
+:py:class:`ProjectSummary`:
+
+First, set up the summary:
+
+.. code-block:: pycon
+
+    >>> from pygount import ProjectSummary
+    >>> project_summary = ProjectSummary()
+
+Next, find some files to analyze:
+
+.. code-block:: pycon
+
+    >>> from glob import glob
+    >>> source_paths = glob("pygount/*.py") + glob("*.md")
+    >>> source_paths
+    ['pygount/command.py', 'pygount/analysis.py', 'pygount/write.py', 'pygount/__init__.py', 'pygount/xmldialect.py', 'pygount/summary.py', 'pygount/common.py', 'pygount/lexers.py', 'README.md', 'CONTRIBUTING.md', 'CHANGES.md']
+
+Then analyze them:
+
+.. code-block:: pycon
+
+    >>> for source_path in source_paths:
+    ...     source_analysis = SourceAnalysis.from_file(source_path, "pygount")
+    ...     project_summary.add(source_analysis)
+
+Finally, take a look at the information collected, for example by printing the values of
+:py:attr:`ProjectSummary.language_to_language_summary_map`
+
+    >>> for language_summary in project_summary.language_to_language_summary_map.values():
+    ...   print(language_summary)
+    ...
+    LanguageSummary(language='Python', file_count=8, code=1232, documentation=295, empty=331, string=84)
+    LanguageSummary(language='markdown', file_count=3, code=64, documentation=0, empty=29, string=14)
 
 
 Reference
diff --git a/docs/changes.rst b/docs/changes.rst
index 59f491f..77793a9 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -5,17 +5,20 @@ Changes
 
 This chapter describes the changes coming with each new version of pygount.
 
-Version 1.2.1, 2020-03-31
+Version 1.2.1, 2020-04-02
 
 * Fixed broken links in README on PyPI my moving the documentation to
-  `ReadTheDocs <https://pygount.readthedocs.io/en/latest/index.html>`_.
-* Cleaned up API:
+  `ReadTheDocs <https://pygount.readthedocs.io/>`_.
+* Improved API:
 
   * Changed factory functions to methods and added deprecation warnings:
 
-    * :py:func:`source_analysis` --> :py:meth:`SourceAnalysis.from_file`
-    * :py:func:`pseudo_source_analysis` --> :py:meth:`SourceAnalysis.from_state`
+    * :py:func:`source_analysis` → :py:meth:`SourceAnalysis.from_file`
+    * :py:func:`pseudo_source_analysis` → :py:meth:`SourceAnalysis.from_state`
 
+  * Changed attributes in :py:class:`SourceAnalysis` to read-only properties.
+  * Renamed properties holding counts from :py:attr:`xxx` to
+    :py:attr:`xxx_count`.
   * Added API reference to documentation.
   * Added a couple of type hints and assertions.
 
diff --git a/docs/contributing.rst b/docs/contributing.rst
index 1f9678d..99e03ef 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -52,6 +52,17 @@ To build and browse the coverage report in HTML format:
     $ open htmlcov/index.html  # macOS only
 
 
+Documentation
+-------------
+
+To build the documentation in HTML format:
+
+.. code-block:: bash
+
+    $ make -C docs html
+    $ open docs/_build/html/index.html  # macOS only
+
+
 Coding guidelines
 -----------------
 
diff --git a/pygount/analysis.py b/pygount/analysis.py
index 9128297..40154a0 100644
--- a/pygount/analysis.py
+++ b/pygount/analysis.py
@@ -12,7 +12,7 @@ import logging
 import os
 import re
 from enum import Enum
-from typing import Dict, Generator, List, Optional, Pattern, Sequence, Set, Tuple
+from typing import Dict, Generator, List, Optional, Pattern, Sequence, Set, Tuple, Union
 
 import pygments.lexer
 import pygments.lexers
@@ -344,26 +344,68 @@ class SourceAnalysis:
 
     @property
     def language(self) -> str:
+        """
+        The programming language the analyzed source code is written in; if
+        :py:attr:`state` does not equal :py:attr:`SourceState.analyzed` this
+        will be a pseudo language.
+        """
         return self._language
 
     @property
     def group(self) -> str:
+        """
+        Group the source code belongs to; this can be any text useful to group
+        the files later on. It is perfectly valid to put all files in the same
+        group.
+
+        (Note: this property is mostly there for compatibility with the
+        original SLOCCount.)
+        """
         return self._group
 
+    @property
+    def code_count(self) -> int:
+        """number of lines containing code"""
+        return self._code
+
+    @property
+    def documentation_count(self) -> int:
+        """number of lines containing documentation (resp. comments)"""
+        return self._documentation
+
+    @property
+    def empty_count(self) -> int:
+        """
+        number of empty lines, including lines containing only white space,
+        white characters or white code words
+
+        See also: :py:func:`white_characters`, :py:func:`white_code_words`
+        """
+        return self._empty
+
+    @property
+    def string_count(self) -> int:
+        """number of lines containing only strings but no other code"""
+        return self._string
+
     @property
     def code(self) -> int:
+        # TODO #47: Remove deprecated property.
         return self._code
 
     @property
     def documentation(self) -> int:
+        # TODO #47: Remove deprecated property.
         return self._documentation
 
     @property
     def empty(self) -> int:
+        # TODO #47: Remove deprecated property.
         return self._empty
 
     @property
     def string(self) -> int:
+        # TODO #47: Remove deprecated property.
         return self._string
 
     @property
@@ -374,9 +416,16 @@ class SourceAnalysis:
         return self._state
 
     @property
-    def state_info(self) -> Optional[str]:
+    def state_info(self) -> Optional[Union[str, Exception]]:
         """
-        Possible additional information about :py:attr:`state`.
+        Possible additional information about :py:attr:`state`:
+
+        * :py:attr:`SourceState.duplicate`: path to the original source file
+          the :py:attr:`path` is a duplicate of
+        * :py:attr:`SourceState.error`: the :py:exc:`Exception` causing the
+          error
+        * :py:attr:`SourceState.generated`: a human readable explanation why
+          the file is considered to be generated
         """
         return self._state_info
 
@@ -392,8 +441,8 @@ class SourceAnalysis:
             self.__class__.__name__, self.path, self.language, self.group, self.state.name
         )
         if self.state == SourceState.analyzed:
-            result += ", code={0}, documentation={1}, empty={2}, string={3}".format(
-                self.code, self.documentation, self.empty, self.string
+            result += ", code_count={0}, documentation_count={1}, empty_count={2}, string_count={3}".format(
+                self.code_count, self.documentation_count, self.empty_count, self.string_count
             )
         if self.state_info is not None:
             result += ", state_info={0!r}".format(self.state_info)
diff --git a/pygount/summary.py b/pygount/summary.py
index 8d7061c..74c08d9 100644
--- a/pygount/summary.py
+++ b/pygount/summary.py
@@ -5,7 +5,7 @@ Summaries of analyses of multiple source codes.
 # All rights reserved. Distributed under the BSD License.
 import functools
 import re
-from typing import Dict
+from typing import Dict, Hashable
 
 from .analysis import SourceAnalysis
 
@@ -21,19 +21,51 @@ class LanguageSummary:
 
     def __init__(self, language: str):
         self._language = language
-        self.code = 0
-        self.documentation = 0
-        self.file_count = 0
-        self.empty = 0
-        self.string = 0
-        self.is_pseudo_language = _PSEUDO_LANGUAGE_REGEX.match(self.language) is not None
+        self._code_count = 0
+        self._documentation_count = 0
+        self._empty_count = 0
+        self._file_count = 0
+        self._string_count = 0
+        self._is_pseudo_language = _PSEUDO_LANGUAGE_REGEX.match(self.language) is not None
 
     @property
     def language(self) -> str:
+        """the language to be summarized"""
         return self._language
 
-    def sort_key(self):
-        return self.code, self.documentation, self.string, self.empty, self.language
+    @property
+    def code_count(self) -> int:
+        """sum lines of code for this language"""
+        return self._code_count
+
+    @property
+    def documentation_count(self) -> int:
+        """sum lines of documentation for this language"""
+        return self._documentation_count
+
+    @property
+    def empty_count(self) -> int:
+        """sum empty lines for this language"""
+        return self._empty_count
+
+    @property
+    def file_count(self) -> int:
+        """number of source code files for this language"""
+        return self._file_count
+
+    @property
+    def string_count(self) -> int:
+        """sum number of lines containing only strings for this language"""
+        return self._string_count
+
+    @property
+    def is_pseudo_language(self) -> bool:
+        """``True`` if the language is not a real programming language"""
+        return self._is_pseudo_language
+
+    def sort_key(self) -> Hashable:
+        """sort key to sort multiple languages by importance"""
+        return self.code_count, self.documentation_count, self.string_count, self.empty_count, self.language
 
     def __eq__(self, other):
         return self.sort_key() == other.sort_key()
@@ -48,18 +80,18 @@ class LanguageSummary:
         assert source_analysis is not None
         assert source_analysis.language == self.language
 
-        self.file_count += 1
+        self._file_count += 1
         if source_analysis.is_countable:
-            self.code += source_analysis.code
-            self.documentation += source_analysis.documentation
-            self.empty += source_analysis.empty
-            self.string += source_analysis.string
+            self._code_count += source_analysis.code_count
+            self._documentation_count += source_analysis.documentation_count
+            self._empty_count += source_analysis.empty_count
+            self._string_count += source_analysis.string_count
 
     def __repr__(self):
         result = "{0}(language={1!r}, file_count={2}".format(self.__class__.__name__, self.language, self.file_count)
         if not self.is_pseudo_language:
-            result += ", code={0}, documentation={1}, empty={2}, string={3}".format(
-                self.code, self.documentation, self.empty, self.string
+            result += ", code_count={0}, documentation_count={1}, empty_count={2}, string_count={3}".format(
+                self.code_count, self.documentation_count, self.empty_count, self.string_count
             )
         result += ")"
         return result
@@ -72,12 +104,12 @@ class ProjectSummary:
 
     def __init__(self):
         self._language_to_language_summary_map = {}
-        self.total_code_count = 0
-        self.total_documentation_count = 0
-        self.total_empty_count = 0
-        self.total_string_count = 0
-        self.total_file_count = 0
-        self.total_line_count = 0
+        self._total_code_count = 0
+        self._total_documentation_count = 0
+        self._total_empty_count = 0
+        self._total_string_count = 0
+        self._total_file_count = 0
+        self._total_line_count = 0
 
     @property
     def language_to_language_summary_map(self) -> Dict[str, LanguageSummary]:
@@ -86,11 +118,37 @@ class ProjectSummary:
         """
         return self._language_to_language_summary_map
 
+    @property
+    def total_code_count(self) -> int:
+        return self._total_code_count
+
+    @property
+    def total_documentation_count(self) -> int:
+        return self._total_documentation_count
+
+    @property
+    def total_empty_count(self) -> int:
+
+        return self._total_empty_count
+
+    @property
+    def total_string_count(self) -> int:
+
+        return self._total_string_count
+
+    @property
+    def total_file_count(self) -> int:
+        return self._total_file_count
+
+    @property
+    def total_line_count(self) -> int:
+        return self._total_line_count
+
     def add(self, source_analysis: SourceAnalysis) -> None:
         """
         Add counts from ``source_analysis`` to total counts.
         """
-        self.total_file_count += 1
+        self._total_file_count += 1
         language_summary = self.language_to_language_summary_map.get(source_analysis.language)
         if language_summary is None:
             language_summary = LanguageSummary(source_analysis.language)
@@ -98,13 +156,16 @@ class ProjectSummary:
         language_summary.add(source_analysis)
 
         if source_analysis.is_countable:
-            self.total_code_count += source_analysis.code
-            self.total_documentation_count += source_analysis.documentation
-            self.total_empty_count += source_analysis.empty
-            self.total_line_count += (
-                source_analysis.code + source_analysis.documentation + source_analysis.empty + source_analysis.string
+            self._total_code_count += source_analysis.code_count
+            self._total_documentation_count += source_analysis.documentation_count
+            self._total_empty_count += source_analysis.empty_count
+            self._total_line_count += (
+                source_analysis.code_count
+                + source_analysis.documentation_count
+                + source_analysis.empty_count
+                + source_analysis.string_count
             )
-            self.total_string_count += source_analysis.string
+            self._total_string_count += source_analysis.string_count
 
     def __repr__(self):
         return "{0}(total_file_count={1}, total_line_count={2}, " "languages={3}".format(
diff --git a/pygount/write.py b/pygount/write.py
index 850ebf6..8308ae3 100644
--- a/pygount/write.py
+++ b/pygount/write.py
@@ -56,7 +56,7 @@ class LineWriter(BaseWriter):
         self.template = "{0}\t{1}\t{2}\t{3}"
 
     def add(self, source_analysis):
-        source_line_count = source_analysis.code + source_analysis.string
+        source_line_count = source_analysis.code_count + source_analysis.string_count
         line_to_write = self.template.format(
             source_line_count, source_analysis.language, source_analysis.group, source_analysis.path
         )
@@ -86,9 +86,9 @@ class ClocXmlWriter(BaseWriter):
     def add(self, source_analysis):
         super().add(source_analysis)
         file_attributes = {
-            "blank": str(source_analysis.empty),
-            "code": str(source_analysis.code + source_analysis.string),
-            "comment": str(source_analysis.documentation),
+            "blank": str(source_analysis.empty_count),
+            "code": str(source_analysis.code_count + source_analysis.string_count),
+            "comment": str(source_analysis.documentation_count),
             "language": source_analysis.language,
             "name": source_analysis.path,
         }
@@ -156,10 +156,10 @@ class SummaryWriter(BaseWriter):
             file_count_width = digit_width(language_summary.file_count)
             if file_count_width > max_file_count_width:
                 max_file_count_width = file_count_width
-            code_width = digit_width(language_summary.code)
+            code_width = digit_width(language_summary.code_count)
             if code_width > max_code_width:
                 max_code_width = code_width
-            documentation_width = digit_width(language_summary.documentation)
+            documentation_width = digit_width(language_summary.documentation_count)
             if documentation_width > max_documentation_width:
                 max_documentation_width = documentation_width
         digits_after_dot = self._PERCENTAGE_DIGITS_AFTER_DOT
@@ -211,7 +211,7 @@ class SummaryWriter(BaseWriter):
             "{6:>{percentage_width}.0{digits_after_dot}f}"
         )
         for language_summary in sorted(self.project_summary.language_to_language_summary_map.values(), reverse=True):
-            code_count = language_summary.code
+            code_count = language_summary.code_count
             code_percentage = (
                 code_count / self.project_summary.total_code_count * 100
                 if self.project_summary.total_code_count != 0
@@ -223,7 +223,7 @@ class SummaryWriter(BaseWriter):
             ), "if there is at least 1 language summary, there must be a file count too"
             file_percentage = file_count / self.project_summary.total_file_count * 100
             documentation_percentage = (
-                language_summary.documentation / self.project_summary.total_documentation_count * 100
+                language_summary.documentation_count / self.project_summary.total_documentation_count * 100
                 if self.project_summary.total_documentation_count != 0
                 else 0.0
             )
@@ -233,7 +233,7 @@ class SummaryWriter(BaseWriter):
                 file_percentage,
                 code_count,
                 code_percentage,
-                language_summary.documentation,
+                language_summary.documentation_count,
                 documentation_percentage,
                 digits_after_dot=digits_after_dot,
                 max_code_width=max_code_width,
diff --git a/setup.cfg b/setup.cfg
index f68a4e0..164e957 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [flake8]
-ignore = E203
+ignore = E203,W503
 max-line-length = 120
 
 [tool:pytest]


Test patch:
diff --git a/tests/test_analysis.py b/tests/test_analysis.py
index b0156ce..9d19e04 100644
--- a/tests/test_analysis.py
+++ b/tests/test_analysis.py
@@ -115,8 +115,8 @@ class FileAnalysisTest(TempFolderTest):
         )
         source_analysis = analysis.SourceAnalysis.from_file(test_bat_path, "test", encoding="utf-8")
         assert source_analysis.language == "Batchfile"
-        assert source_analysis.code == 1
-        assert source_analysis.documentation == 2
+        assert source_analysis.code_count == 1
+        assert source_analysis.documentation_count == 2
 
     def test_fails_on_unknown_magic_encoding_comment(self):
         test_path = self.create_temp_file(
@@ -135,8 +135,8 @@ class FileAnalysisTest(TempFolderTest):
         )
         source_analysis = analysis.SourceAnalysis.from_file(test_oracle_sql_path, "test", encoding="utf-8")
         assert source_analysis.language.lower().endswith("sql")
-        assert source_analysis.code == 2
-        assert source_analysis.documentation == 1
+        assert source_analysis.code_count == 2
+        assert source_analysis.documentation_count == 1
 
     def test_can_analyze_webfocus(self):
         test_fex_path = self.create_temp_file(
@@ -144,8 +144,8 @@ class FileAnalysisTest(TempFolderTest):
         )
         source_analysis = analysis.SourceAnalysis.from_file(test_fex_path, "test", encoding="utf-8")
         assert source_analysis.language == "WebFOCUS"
-        assert source_analysis.code == 2
-        assert source_analysis.documentation == 1
+        assert source_analysis.code_count == 2
+        assert source_analysis.documentation_count == 1
 
     def test_can_analyze_xml_dialect(self):
         build_xml_path = self.create_temp_file("build.xml", EXAMPLE_ANT_CODE)
@@ -162,14 +162,14 @@ class FileAnalysisTest(TempFolderTest):
         binary_path = self.create_temp_binary_file("some_django.mo", b"hello\0world!")
         source_analysis = analysis.SourceAnalysis.from_file(binary_path, "test", encoding="utf-8")
         assert source_analysis.state == analysis.SourceState.binary
-        assert source_analysis.code == 0
+        assert source_analysis.code_count == 0
 
 
 def test_can_repr_source_analysis_from_file():
     source_analysis = analysis.SourceAnalysis("some.py", "Python", "some", 1, 2, 3, 4, analysis.SourceState.analyzed)
     expected_source_analysis_repr = (
         "SourceAnalysis(path='some.py', language='Python', group='some', "
-        "state=analyzed, code=1, documentation=2, empty=3, string=4)"
+        "state=analyzed, code_count=1, documentation_count=2, empty_count=3, string_count=4)"
     )
     assert repr(source_analysis) == expected_source_analysis_repr
     assert repr(source_analysis) == str(source_analysis)
@@ -205,6 +205,14 @@ def test_can_guess_lexer_for_plain_text():
     assert lexer.name == "Text"
 
 
+def test_can_use_deprecated_counts():
+    source_analysis = analysis.SourceAnalysis("some.py", "Python", "some", 1, 2, 3, 4, analysis.SourceState.analyzed)
+    assert source_analysis.code == source_analysis.code_count
+    assert source_analysis.documentation == source_analysis.documentation_count
+    assert source_analysis.empty == source_analysis.empty_count
+    assert source_analysis.string == source_analysis.string_count
+
+
 class EncodingTest(TempFolderTest):
     _ENCODING_TO_BOM_MAP = dict((encoding, bom) for bom, encoding in analysis._BOM_TO_ENCODING_MAP.items())
     _TEST_CODE = "x = '\u00fd \u20ac'"
@@ -331,7 +339,7 @@ class SizeTest(TempFolderTest):
         empty_py_path = self.create_temp_binary_file("empty.py", b"")
         source_analysis = analysis.SourceAnalysis.from_file(empty_py_path, "test", encoding="utf-8")
         assert source_analysis.state == analysis.SourceState.empty
-        assert source_analysis.code == 0
+        assert source_analysis.code_count == 0
 
 
 def test_can_analyze_project_markdown_files():
@@ -339,8 +347,8 @@ def test_can_analyze_project_markdown_files():
     for text_path in glob.glob(os.path.join(project_root_folder, "*.md")):
         source_analysis = analysis.SourceAnalysis.from_file(text_path, "test")
         assert source_analysis.state == analysis.SourceState.analyzed
-        assert source_analysis.documentation > 0
-        assert source_analysis.empty > 0
+        assert source_analysis.documentation_count > 0
+        assert source_analysis.empty_count > 0
 
 
 def test_has_no_duplicate_in_pygount_source():
diff --git a/tests/test_summary.py b/tests/test_summary.py
index f97b2ff..919c5bf 100644
--- a/tests/test_summary.py
+++ b/tests/test_summary.py
@@ -9,7 +9,8 @@ def test_can_repr_language_summary():
     language_summary = LanguageSummary("Python")
     language_summary.add(SourceAnalysis("some.py", "Python", "some", 2, 3, 4, 5, SourceState.analyzed))
     expected_language_summary_repr = (
-        "LanguageSummary(language='Python', file_count=1, code=2, documentation=3, empty=4, string=5)"
+        "LanguageSummary(language='Python', file_count=1, "
+        "code_count=2, documentation_count=3, empty_count=4, string_count=5)"
     )
     assert repr(language_summary) == expected_language_summary_repr
     assert repr(language_summary) == str(language_summary)