Repository: roskakori/pygount
Pull request #46 (2020-04-01 02:45:47)

#44 Cleaned up documentation.


Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1d6f42e..ce671b9 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -14,32 +14,31 @@ jobs:
       MAIN_PYTHON_VERSION: 3.7
 
     steps:
-    - uses: actions/checkout@v2
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v1
-      with:
-        python-version: ${{ matrix.python-version }}
-    - name: Install dependencies
-      run: |
-        python -m pip install --upgrade pip
-        pip install -r dev-requirements.txt
-    - name: Run the test suite
-      env:
-        COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
-      run: |
-        python setup.py develop
-        pytest --verbose
-        if [[ ${{ matrix.python-version }} == $MAIN_PYTHON_VERSION ]]; then coveralls; fi
-    - name: Check code quality
-      run: |
-        if [[ ${{ matrix.python-version }} == $MAIN_PYTHON_VERSION ]]; then pre-commit run --all-files; fi
-    - name: Build and install pygount package
-      run: |
-        python setup.py bdist_wheel
-        if [[ ${{ matrix.python-version }} == $MAIN_PYTHON_VERSION ]]; then twine check dist/pygount-*.whl; fi
-        pip install dist/pygount-*.whl
-    - name: Build documentation
-      run: |
-        cd docs
-        pip install -r requirements.txt
-        make html
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v1
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r dev-requirements.txt
+      - name: Run the test suite
+        env:
+          COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
+        run: |
+          python setup.py develop
+          pytest --verbose
+          if [[ ${{ matrix.python-version }} == $MAIN_PYTHON_VERSION ]]; then coveralls; fi
+      - name: Check code quality
+        run: |
+          if [[ ${{ matrix.python-version }} == $MAIN_PYTHON_VERSION ]]; then pre-commit run --all-files; fi
+      - name: Build and install pygount package
+        run: |
+          python setup.py bdist_wheel
+          if [[ ${{ matrix.python-version }} == $MAIN_PYTHON_VERSION ]]; then twine check dist/pygount-*.whl; fi
+          pip install dist/pygount-*.whl
+      - name: Build documentation
+        run: |
+          pip install -r docs/requirements.txt
+          make -C docs html
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9f0a515..eb25ff2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,26 +1,26 @@
 # See https://pre-commit.com for more information
 # See https://pre-commit.com/hooks.html for more hooks
 repos:
--   repo: https://github.com/ambv/black
+  - repo: https://github.com/ambv/black
     rev: stable
     hooks:
-    -   id: black
+      - id: black
         language_version: python3.7
 
--   repo: https://gitlab.com/pycqa/flake8
+  - repo: https://gitlab.com/pycqa/flake8
     rev: 3.7.3
     hooks:
-    -   id: flake8
+      - id: flake8
 
--   repo: https://github.com/pre-commit/pre-commit-hooks
+  - repo: https://github.com/pre-commit/pre-commit-hooks
     rev: v2.5.0
     hooks:
-    -   id: trailing-whitespace
-    -   id: end-of-file-fixer
-    -   id: mixed-line-ending
-    -   id: check-yaml
-    -   id: check-xml
-    -   id: check-added-large-files
-    -   id: check-ast
-    -   id: check-byte-order-marker
-    -   id: requirements-txt-fixer
+      - id: trailing-whitespace
+      - id: end-of-file-fixer
+      - id: mixed-line-ending
+      - id: check-yaml
+      - id: check-xml
+      - id: check-added-large-files
+      - id: check-ast
+      - id: check-byte-order-marker
+      - id: requirements-txt-fixer
diff --git a/CHANGES.md b/CHANGES.md
index dae8ce2..e9e9800 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,142 +1,5 @@
-# Pygount version history
+# Version history
 
-Version 1.2.0, 2020-03-30
-
-* Added file count to summary.
-* Changed installation to fail when attempting to install on Python earlier
-  than 3.5.
-* Improved API:
-  * Changed `SourceAnalysis.state` to be a proper enum instead of a string.
-  * Added`ProjectSummary` to summarize multiple files.
-* Cleaned up project:
-  * Changed continuous integration from Travis CI to Github actions in the hope
-    that the CI build does not automatically break after a while because
-    things constantly change in the CI backend.
-  * Changed README format from reStructuredText to Markdown.
-  * Improved badges in README: added a badge for supported Python versions
-    and unified the layout by using <https://shields.io>.
-  * Removed obsolete development files (for ant, tox etc).
-
-Version 1.1.0, 2020-03-10
-
-* Fixed `--folders_to_skip` and `--names-to-skip` which simply were ignored
-  (contributed by pclausen, issue
-  [#17](https://github.com/roskakori/pygount/issues/17)).
-* Added option `--format=summary` to get a language overview and sum total
-  (based on a contribution by Yuriy Petrovskiy, issue
-  [#16](https://github.com/roskakori/pygount/issues/16)).
-* Added Python 3.7 and 3.8 to the list of supported versions.
-* Dropped support for Python 3.3 and 3.4, mostly because it became hard to
-  test without going through major hoops.
-
-Version 1.0.0, 2017-07-04
-
-* Fixed confusing warning about XML file `<unknown>` caused by SAX parser.
-  As a workaround, `<unknown>` is now replaced by the actual path of the
-  XML file that cannot be parsed.
-* Added Python 3.6 to the list of supported versions  (issue
-  [#14](https://github.com/roskakori/pygount/issues/14)).
-
-Version 0.9, 2017-05-04
-
-* Fixed `AssertionError` when option `--encoding=chardet` was specified.
-* Changed warning message "no fallback encoding specified, using](encoding>"
-  to a debug message because it did not add any interesting information as
-  the encoding actually used is visible in the info message for each file.
-* Added detection of binary files and excluded them from the analysis. In
-  particular Django model objects (`*.mo`) are not considered Modelica
-  source code anymore (issue
-  [#11](https://github.com/roskakori/pygount/issues/11)).
-* Added detection of DocBook XML by DTD (issue
-  [#10](https://github.com/roskakori/pygount/issues/10)).
-* Added support for suffices to indicate PL/SQL files according to
-  `Oracle FAQ entry on file extensions](http://www.orafaq.com/wiki/File_extensions)
-  (issue [#12](https://github.com/roskakori/pygount/issues/12)).
-* Added possibility to specify a fallback encoding for encoding 'chardet'. Use
-  e.g. `--encoding=chardet;cp1252`.
-
-Version 0.8, 2016-10-07
-
-* Fixed option `--verbose`. Now each analyzed source code results in at least
-  one informational message in the log.
-* Added detection of duplicates using size and then MD5 code as criteria (issue
-  [#2](https://github.com/roskakori/pygount/issues/2)). Use the option
-  `--duplicates` to still count duplicate source code.
-* Improved detetion of programming language, which is now more consistent and
-  yields the same language between Python invocations.
-
-Version 0.7, 2016-09-28
-
-* Fixed that option `--generated` was ignored.
-* Added support for a couple of languages not supported by `pygments` yet:
-
-  * m4, VBScript and WebFOCUS use minimalistic lexers that can distinguish
-    between comments and code.
-  * OMG IDL repurposes the existing Java lexer.
-
-* Added detection of certain XML dialects as separate language (issue
-  [#8](https://github.com/roskakori/pygount/issues/8)).
-
-Version 0.6, 2016-09-26
-
-* Fixed that source files could end up as `__error__` if the first non ASCII
-  characters showed up only after 16 kilobyte and the encoding was not UTF-8.
-  Now pygount attempts to read the whole file as UTF-8 before assuming it
-  actually is UTF-8.
-* Changed lines in plain text files to count as comments (issue
-  [#9](https://github.com/roskakori/pygount/issues/9)). Before pygments
-  treated them as `ResourceBundle`.
-* Changed that empty files have `__empty__` as language (issue
-  [#7](https://github.com/roskakori/pygount/issues/7)).
-* Extended workaround for
-  [pygments issue #1284](https://bitbucket.org/birkenfeld/pygments-main/issues/1284)
-  to replace any lexer `*+Evoque` by `*`.
-
-Version 0.5, 2016-09-22
-
-* Added that generated source code is excluded from analysis (issue
-  [#1](https://github.com/roskakori/pygount/issues/1)). Use option
-  `--generated` to specify patterns that indicate generated code.
-* Added workaround for pygments sometimes detecting the same XML file as XML
-  and other times as XML+Evoque (probably depending on the hash seed). Now
-  XML+Evoque  is always changed to XML.
-* Added `__pycache__` as default `--folder-to-skip`.
-* Added notes on pseudo languages for source code that cannot be analyzed.
-
-Version 0.4, 2016-09-11
-
-* Fixed `LookupError` on broken encoding in magic comment (issue
-  [#4](https://github.com/roskakori/pygount/issues/4)).
-* Added options `--folders-to-skip` and `--names-to-skip` to specify which
-  files should be excluded from analysis.
-* Added comma (`,`) and colon (`:`) to list of "white characters" that do
-  not count as code if there is nothing else in the line.
-* Improved pattern matching: for all options that according to `--help`
-  take `PATTERNS` you can now specify that the patterns are regular
-  expressions instead of shell patterns (using `[regex]`) and that they
-  should extend the default patterns (using `[...]`).
-* Improved documentation: added notes on how code is counted and how pygount
-  compares to other similar tools.
-
-Version 0.3, 2016-08-20
-
-* Fixed `@rem` comments in DOS batch files (issue
-  [#3](https://github.com/roskakori/pygount/issues/3)).
-* Cleaned up code.
-
-Version 0.2, 2016-07-10
-
-* Fixed that files starting with underscore (e.g. `__init__.py`) were
-  excluded from analysis.
-* Changed `chardet` package to be optional.
-* Added possibility to specify single files and glob patterns to analyze.
-* Added that lines containing only certain characters are treated as white
-  space instead of code. Currently this concerns brackets (`()[]{}`) and
-  semicolon (`;`).
-* Added that Python's `pass` statement is treated as white space instead of
-  code.
-* Cleaned up and (slightly) optimized code.
-
-Version 0.1, 2016-07-05
-
-* Initial public release.
+For more information about which versions of pygount included what changes
+read the
+[respective chapter of the documentation](https://pygount.readthedocs.io/en/latest/changes.html).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e6c31c4..270cf1b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,59 +1,4 @@
 # Contributing to pygount
 
-
-## Project setup
-
-In case you want to play with the source code or contribute changes proceed as
-follows:
-
-1. Check out the project from GitHub:
-   ```bash
-   $ git clone https://github.com/roskakori/pygount.git
-   $ cd pygount
-   ```
-2. Create and activate a virtual environment:
-   ```bash
-   $ python -m venv venv
-   $ . venv/bin/activate
-   ```
-3. Install the required packages:
-   ```bash
-   $ pip install --upgrade pip
-   $ pip install -r dev-requirements.txt
-   ```
-4. Install the pre-commit hook:
-   ```bash
-   $ pre-commit install
-   ```
-
-
-## Testing
-
-To run the test suite:
-```bash
-$ pytest
-```
-
-To build and browse the coverage report in HTML format:
-```bash
-$ pytest --cov-report=html
-$ open htmlcov/index.html  # macOS only
-```
-
-## Coding guidelines
-
-The code throughout uses a natural naming schema avoiding abbreviations, even
-for local variables and parameters.
-
-Many coding guidelines are automatically enforced (and some even fixed
-automatically) by the pre-commit hook. If you want to check and clean up
-the code without performing a commit, run:
-```bash
-$ pre-commit run --all-files
-```
-
-In particular, this applies [black](https://black.readthedocs.io/en/stable/),
-[flake8](https://flake8.pycqa.org/) and
-[isort](https://pypi.org/project/isort/).
-
-Other than that
+For more information on building pygount and contributing to it, read the
+[respective chapter of the documentation](https://pygount.readthedocs.io/en/latest/contributing.html).
diff --git a/README.md b/README.md
index c5745ef..26724e7 100644
--- a/README.md
+++ b/README.md
@@ -20,262 +20,79 @@ Pygount is open source and distributed under the
 code is available from https://github.com/roskakori/pygount.
 
 
-## Download and installation
+## Quickstart
 
-Pygount is available from [PyPI](https://pypi.python.org/pypi/pygount) and can
-be installed by running:
+For installation run
 
 ```bash
 $ pip install pygount
 ```
 
-
-## Usage
-
-Simply run and specify the folder to analyze recursively, for example:
+To get a list of line counts for a projects stored in a certain folder run for
+example:
 
 ```bash
-$ pygount ~/development/sometool
+$ pygount ~/projects/exammple
 ```
 
-If you omit the folder, the current folder of your shell is used as starting
-point. Apart from folders you can also specify single files and shell patterns
-(using `?`, `*` and ranges like `[a-z]`).
-
-Certain files and folders are automatically excluded from the analysis:
-
-* files starting with dot (`.`) or ending in tilda (`~`)
-* folders starting with dot (`.`) or named `_svn`.
-
-To specify alternative patterns, use `--folders-to-skip` and
-`--names-to-skip`. Both take a comma separated list of patterns, see below
-on the pattern syntax. To for example also prevent folders starting with two
-underscores (`_`) from being analyzed, specify
-`--folders-to-skip=[...],__*`.
-
-To limit the analysis on certain file types, you can specify a comma separated
-list of suffixes to take into account, for example `--suffix=py,sql,xml`.
-
-By default the result of the analysis are written to the standard output in a
-format similar to sloccount. To redirect the output to a file, use e.g.
-`--out=counts.txt`. To change the format to an XML file similar to cloc, use
-`--format=cloc-xml`.
-
-To just get a quick grasp of the languages used in a project and their
-respective importance use `--format=summary` which provides a language
-overview and a sum total. For example pygount's summary looks like this:
+To limit the analysis to certain file types identified by their suffix:
 
 ```bash
-    Language      Code    %     Comment    %
-----------------  ----  ------  -------  ------
-Python            1730   83.94      250   78.12
-reStructuredText   271   13.15        0    0.00
-Ant                 38    1.84        1    0.31
-INI                 10    0.49        1    0.31
-Bash                 8    0.39        2    0.62
-TOML                 4    0.19        0    0.00
-Text                 0    0.00       66   20.62
-----------------  ----  ------  -------  ------
-Sum total         2061              320
+$ pygount --suffix=cfg,py,yml  ~/projects/exammple
 ```
 
-The summary output is designed for human readers and the column widths adjust
-to the data.
-
-
-## Patterns
-
-Some command line arguments take patterns as values.
-
-By default, patterns are shell patterns using `*`, `?` and ranges like
-`[a-z]` as placeholders. Depending on your platform, the are case sensitive
-(Unix) or not (Mac OS, Windows).
-
-If a pattern starts with `[regex]` you can specify a comma separated list
-of regular expressions instead using all the constructs supported by the
-[Python regular expression syntax](https://docs.python.org/3/library/re.html#regular-expression-syntax).
-Regular expressions are case sensitive unless they include a `(?i)` flag.
-
-If the first actual pattern is `[...]`, default patterns are included.
-Without it, defaults are ignored and only the pattern explicitly stated are
-taken into account.
-
-So for example to specify that generated code can also contain the German word
-"generiert" in a case insensivie way use
-`--generated="[regex][...](?i).*generiert"`.
-
-
-## Source code encoding
-
-When reading source code, pygount automatically detects the encoding. It uses
-a simple algorithm where it recognizes BOM, XML declarations such as:
-
-```
-<?xml encoding='cp1252'?>
-```
-
-and "magic" comments such as:
-
-```python
-# -*- coding: cp1252 -*-
-```
-
-If the file does not have an appropriate heading, pygount attempts to read it
-using UTF-8. If this fails, it reads the file using a fallback encoding (by
-default CP1252) and ignores any encoding errors.
-
-You can change this behavior using the `--encoding` option:
-
-* To keep the automatic analysis and use a different fallback encoding specify
-  for example `--encoding=automatic;iso-8859-15`.
-* To use an automatic detection based on heuristic, use
-  `--encoding=chardet`. For this to work, the
-  `chardet <https://pypi.python.org/pypi/chardet>`_ package must be installed,
-* To use a specific encoding (for all files analyzed), use for example
-  `--encoding=iso-8859-15`.
-
-
-## Pseudo languages
-
-If a source code is not counted, the number of lines is 0 and the language
-shown is a pseudo language indicating the reason:
-
-* `__binary__` - the source code is a binary file; the detection of binary files
-  first ensures that file does not start with a BOM for UTF-8, UTF-16 or
-  UTF-32 (which indicates text files). After that it checks for zero bytes
-  within the initial 8192 bytes of the file.
-* `__duplicate__` - the source code is a bytewise identical copy of another
-  file; enable the command line option `--duplicates` to also count code in
-  duplicates (and gain a minor performance improvement).
-* `__empty__` - the source code is an empty file with a size of 0 bytes.
-* `__error__` - the source code could not be parsed e.g. due to an I/O error.
-* `__generated__` - the source code is generated according to the command line
-  option `--generated`.
-* `__unknown__` - pygments does not provide a lexer to parse the source code.
-
-
-## Other information
-
-To get a description of all the available command line options, run:
+To get a summary of each programming language with sum counts and percentage:
 
 ```bash
-$ pygount --help
+$ pygount --format=summary ~/projects/exammple
 ```
 
-To get the version number, run:
-
-```bash
-$ pygount --version
+As an example here is the summary output for pygount's own source code:
 
+```
+    Language      Files    %     Code    %     Comment    %
+----------------  -----  ------  ----  ------  -------  ------
+Python               19   51.35  1924   72.99      322   86.10
+reStructuredText      7   18.92   332   12.59        7    1.87
+markdown              3    8.11   327   12.41        1    0.27
+Batchfile             1    2.70    24    0.91        1    0.27
+YAML                  1    2.70    11    0.42        2    0.53
+Makefile              1    2.70     9    0.34        7    1.87
+INI                   1    2.70     5    0.19        0    0.00
+TOML                  1    2.70     4    0.15        0    0.00
+Text                  3    8.11     0    0.00       34    9.09
+----------------  -----  ------  ----  ------  -------  ------
+Sum total            37          2636              374
 ```
 
-
-## Continuous integration
-
-Pygount can produce output that can be processed by the
+Plenty of tools can post process SLOC information, for example the
 [SLOCCount plug-in](https://wiki.jenkins-ci.org/display/JENKINS/SLOCCount+Plugin)
 for the [Jenkins](https://jenkins.io/) continuous integration server.
 
-It's recommended to run pygount as one of the first steps in your build
-process before any undesired file like compiler targets or generated source
-code are built.
-
-An example "Execute shell" build step for Jenkins is:
-
-```
-$ pygount --format=cloc-xml --out cloc.xml --suffix=py --verbose
-```
-
-Then add a post-build action "Publish SLOCCount analysis results" and set
-"SLOCCount report" to "cloc.xml".
-
+A popular format for such tools is the XML format used by cloc, which pygount
+also supports and can store in an output file:
 
-## How pygount counts code
-
-Pygount basically counts physical lines of source code.
-
-First, it lexes the code using the lexers `pygments` assigned to it. If
-`pygments` cannot find an appropriate lexer, pygount has a few additional
-internal lexers that can at least distinguish between code and comments:
-
-* m4, VBScript and WebFOCUS use minimalistic lexers that can distinguish
-  between comments and code.
-* OMG IDL repurposes the existing Java lexer.
-
-Furthermore plain text has a separate lexer that counts all lines as comments.
-
-Lines that only contain comment tokens and white space count as comments.
-Lines that only contain white space are not taken into account. Everything
-else counts as code.
-
-If a line contains only "white characters" it is not taken into account
-presumably because the code is only formatted that way to make it easier to
-read. Currently white characters are:
-
-```
-(),:;[]{}
+```bash
+$ pygount --format=cloc-xml --out=cloc.xml ~/projects/exammple
 ```
 
-Because of that, pygount reports about 10 to 20 percent fewer SLOC for C-like
-languages than other similar tools.
+To get a short description of all available command line options use:
 
-For some languages "no operations" are detected and treated as white space.
-For example Python's `pass` or Transact-SQL's `begin` and `end` .
-
-As example consider this Python code:
-
-````python
-class SomeError(Exception):
-    """
-    Some error caused by some issue.
-    """
-    pass
-````
-
-This counts as 1 line of code and 3 lines of comments. The line with `pass`
-is considered a "no operation" and thus not taken into account.
-
-
-## Comparison with other tools
-
-Pygount can analyze more languages than other common tools such as sloccount
-or cloc because it builds on `pygments`, which provides lexers for hundreds
-of languages. This also makes it easy to support another language: simply
-[write your own lexer](http://pygments.org/docs/lexerdevelopment/).
-
-For certain corner cases pygount gives more accurate results because it
-actually lexes the code unlike other tools that mostly look for comment
-markers and can get confused when they show up inside strings. In practice
-though this should not make much of a difference.
-
-Pygount is slower than most other tools. Partially this is due to actually
-lexing instead of just scanning the code. Partially other tools can use
-statically compiled languages such as Java or C, which are generally faster
-than dynamic languages. For many applications though pygount should be
-"fast enough", especially when running as an asynchronous step during a
-continuous integration build.
-
-
-## API
-
-Pygount provides a simple API to integrate in other tools. This however is
-currently still a work in progress and subject to change.
-
-Here's an example on how to analyze one of pygount's own source codes:
-
-```
->>> import pygount
->>> pygount.source_analysis('pygount/analysis.py', 'pygount')
-SourceAnalysis(path='pygount/analysis.py', language='Python', group='pygount', code=302, documentation=66, empty=62, string=23, state='analyzed', state_info=None)
+```bash
+$ pygount --help
 ```
 
+For more information and examples read the documentation chapter on
+[Usage](https://pygount.readthedocs.io/en/latest/usage.html).
 
 ## Contributions
 
-In case you want to play with the source code or contribute improvements, see
-[CONTRIBUTING](CONTRIBUTING.md).
+To report bugs, visit the
+[issue tracker](https://github.com/roskakori/pygount/issues).
 
+In case you want to play with the source code or contribute improvements, see
+[CONTRIBUTING](https://pygount.readthedocs.io/en/latest/contributing.html).
 
 ## Version history
 
-See [CHANGES](CHANGES.md).
+See [CHANGES](https://pygount.readthedocs.io/en/latest/changes.html).
diff --git a/docs/api.rst b/docs/api.rst
index 193a4ce..62ca2f4 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -13,7 +13,7 @@ Here's an example on how to analyze one of pygount's own source codes:
 
     >>> import pygount
     >>> pygount.source_analysis('pygount/analysis.py', 'pygount')
-    SourceAnalysis(path='pygount/analysis.py', language='Python', group='pygount', code=302, documentation=66, empty=62, string=23, state='analyzed', state_info=None)
+    SourceAnalysis(path='pygount/analysis.py', language='Python', group='pygount', state=analyzed, code=464, documentation=108, empty=104, string=22)
 
 
 Reference
diff --git a/docs/changes.rst b/docs/changes.rst
index 910a09c..59f491f 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -1,8 +1,24 @@
 Changes
 =======
 
+.. program:: pygount
+
 This chapter describes the changes coming with each new version of pygount.
 
+Version 1.2.1, 2020-03-31
+
+* Fixed broken links in README on PyPI my moving the documentation to
+  `ReadTheDocs <https://pygount.readthedocs.io/en/latest/index.html>`_.
+* Cleaned up 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`
+
+  * Added API reference to documentation.
+  * Added a couple of type hints and assertions.
+
 Version 1.2.0, 2020-03-30
 
 * Added file count to summary.
@@ -25,7 +41,7 @@ Version 1.2.0, 2020-03-30
 
 Version 1.1.0, 2020-03-10
 
-* Fixed :option:`--folders_to_skip` and :option:`--names-to-skip` which simply
+* Fixed :option:`--folders-to-skip` and :option:`--names-to-skip` which simply
   were ignored (contributed by pclausen, issue
   `#17 <https://github.com/roskakori/pygount/issues/17>`_).
 * Added option ``--format=summary`` to get a language overview and sum total
@@ -45,8 +61,8 @@ Version 1.0.0, 2017-07-04
 
 Version 0.9, 2017-05-04
 
-* Fixed :py:exc:`AssertionError` when option :option:`--encoding=chardet` was
-  specified.
+* Fixed :py:exc:`AssertionError` when option
+  :option:`--encoding=chardet <--encoding>` was specified.
 * Changed warning message "no fallback encoding specified, using](encoding>"
   to a debug message because it did not add any interesting information as
   the encoding actually used is visible in the info message for each file.
@@ -60,7 +76,7 @@ Version 0.9, 2017-05-04
   `Oracle FAQ entry on file extensions <http://www.orafaq.com/wiki/File_extensions>`_
   (issue `#12 <https://github.com/roskakori/pygount/issues/12>`_).
 * Added possibility to specify a fallback encoding for encoding 'chardet'. Use
-  e.g. ``--encoding=chardet;cp1252``.
+  e.g. :option:`--encoding=chardet;cp1252 <--encoding>`.
 
 Version 0.8, 2016-10-07
 
@@ -107,7 +123,7 @@ Version 0.5, 2016-09-22
 * Added workaround for pygments sometimes detecting the same XML file as XML
   and other times as XML+Evoque (probably depending on the hash seed). Now
   XML+Evoque  is always changed to XML.
-* Added :file:`__pycache__` as default :option:`--folder-to-skip`.
+* Added :file:`__pycache__` as default :option:`--folders-to-skip`.
 * Added notes on pseudo languages for source code that cannot be analyzed.
 
 Version 0.4, 2016-09-11
diff --git a/docs/conf.py b/docs/conf.py
index d21b4a9..a056085 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -45,7 +45,7 @@ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 #
-html_theme = "alabaster"
+html_theme = "sphinx_rtd_theme"
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
diff --git a/docs/contributing.rst b/docs/contributing.rst
index def2c70..1f9678d 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -1,5 +1,5 @@
-Contributing to pygount
-=======================
+Contributing
+============
 
 Project setup
 -------------
diff --git a/docs/index.rst b/docs/index.rst
index 372fcfc..5293b68 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,15 +1,25 @@
-.. pygount documentation master file, created by
-   sphinx-quickstart on Mon Mar 30 15:32:08 2020.
-   You can adapt this file completely to your liking, but it should at least
-   contain the root `toctree` directive.
+Pygount
+=======
 
-Welcome to pygount's documentation!
-===================================
+Pygount is a command line tool to scan folders for source code files and
+count the number of source code lines in it. It is similar to tools like
+`sloccount <http://www.dwheeler.com/sloccount/>`_ and
+`cloc <http://cloc.sourceforge.net/>`_ but uses the
+`pygments <http://pygments.org/>`_
+package to parse the source code and consequently can analyze any
+`programming language supported by pygments <http://pygments.org/languages/>`_.
+
+The name is a combination of pygments and count.
+
+Pygount is open source and distributed under the
+`BSD license <https://opensource.org/licenses/BSD-3-Clause>`_. The source
+code is available from https://github.com/roskakori/pygount.
 
 .. toctree::
    :maxdepth: 2
-   :caption: Contents:
+   :caption: Table of contents
 
+   installation
    usage
    continuous-integration
    background
@@ -18,8 +28,7 @@ Welcome to pygount's documentation!
    changes
 
 Indices and tables
-==================
+------------------
 
 * :ref:`genindex`
-* :ref:`modindex`
 * :ref:`search`
diff --git a/docs/installation.rst b/docs/installation.rst
new file mode 100644
index 0000000..400e821
--- /dev/null
+++ b/docs/installation.rst
@@ -0,0 +1,9 @@
+Installation
+------------
+
+Pygount is available from `PyPI <https://pypi.python.org/pypi/pygount>`_ and
+can be installed by running:
+
+.. code-block:: bash
+
+    $ pip install pygount
diff --git a/docs/requirements.txt b/docs/requirements.txt
index c772c79..73c0b9b 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,1 +1,2 @@
 Sphinx==2.4.4
+sphinx_rtd_theme==0.4.3
diff --git a/docs/usage.rst b/docs/usage.rst
index 3006cf5..689d84d 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -4,6 +4,8 @@ Usage
 General
 -------
 
+.. program:: pygount
+
 Simply run and specify the folder to analyze recursively, for example:
 
 .. code-block:: bash
@@ -19,15 +21,23 @@ Certain files and folders are automatically excluded from the analysis:
 * files starting with dot (``.``) or ending in tilda (``~``)
 * folders starting with dot (``.``) or named ``_svn``.
 
+.. option:: --folders-to-skip LIST
+.. option:: --names-to-skip LIST
+
 To specify alternative patterns, use ``--folders-to-skip`` and
 ``--names-to-skip``. Both take a comma separated list of patterns, see below
 on the pattern syntax. To for example also prevent folders starting with two
 underscores (``_``) from being analyzed, specify
 ``--folders-to-skip=[...],__*``.
 
+.. option:: --suffix LIST
+
 To limit the analysis on certain file types, you can specify a comma separated
 list of suffixes to take into account, for example ``--suffix=py,sql,xml``.
 
+.. option:: --out FILE
+.. option:: --format FORMAT
+
 By default the result of the analysis are written to the standard output in a
 format similar to sloccount. To redirect the output to a file, use e.g.
 ``--out=counts.txt``. To change the format to an XML file similar to cloc, use
@@ -37,17 +47,19 @@ To just get a quick grasp of the languages used in a project and their
 respective importance use ``--format=summary`` which provides a language
 overview and a sum total. For example pygount's summary looks like this::
 
-    Language      Code    %     Comment    %
-    ----------------  ----  ------  -------  ------
-    Python            1730   83.94      250   78.12
-    reStructuredText   271   13.15        0    0.00
-    Ant                 38    1.84        1    0.31
-    INI                 10    0.49        1    0.31
-    Bash                 8    0.39        2    0.62
-    TOML                 4    0.19        0    0.00
-    Text                 0    0.00       66   20.62
-    ----------------  ----  ------  -------  ------
-    Sum total         2061              320
+    Language          Files    %     Code    %     Comment    %
+    ----------------  -----  ------  ----  ------  -------  ------
+    Python               19   51.35  1924   72.99      322   86.10
+    reStructuredText      7   18.92   332   12.59        7    1.87
+    markdown              3    8.11   327   12.41        1    0.27
+    Batchfile             1    2.70    24    0.91        1    0.27
+    YAML                  1    2.70    11    0.42        2    0.53
+    Makefile              1    2.70     9    0.34        7    1.87
+    INI                   1    2.70     5    0.19        0    0.00
+    TOML                  1    2.70     4    0.15        0    0.00
+    Text                  3    8.11     0    0.00       34    9.09
+    ----------------  -----  ------  ----  ------  -------  ------
+    Sum total            37          2636              374
 
 The summary output is designed for human readers and the column widths adjust
 to the data.
@@ -71,14 +83,43 @@ If the first actual pattern is ``[...]``, default patterns are included.
 Without it, defaults are ignored and only the pattern explicitly stated are
 taken into account.
 
+.. option:: --generated
+
 So for example to specify that generated code can also contain the German word
 "generiert" in a case insensivie way use
 ``--generated="[regex][...](?i).*generiert"``.
 
 
+Counting duplicates
+-------------------
+
+.. option:: --duplicates
+
+By default pygount prevents multiple source files with exactly the same content
+to be counted again.
+
+For two files to be considered duplicates the following conditions must be met:
+
+#. Both files have the same size.
+#. Both files have the same `MD5 <https://en.wikipedia.org/wiki/MD5>`_
+   hashcode.
+
+This allows for an efficient detection with a very small possibility for false
+positives.
+
+However it also prevents detection of files with only minor differences as
+duplicates. Examples are files that are identical except for additional white
+space, empty lines or different line endings.
+
+If you still want to count duplicates multiple times, specify
+:option:`--duplicates`. This will also result in a minor performance gain of
+the analysis.
+
 Source code encoding
 ----------------------
 
+.. option:: --encoding ENCODING[;FALLBACK]
+
 When reading source code, pygount automatically detects the encoding. It uses
 a simple algorithm where it recognizes BOM, XML declarations such as:
 
@@ -96,15 +137,15 @@ If the file does not have an appropriate heading, pygount attempts to read it
 using UTF-8. If this fails, it reads the file using a fallback encoding (by
 default CP1252) and ignores any encoding errors.
 
-You can change this behavior using the ``--encoding`` option:
+You can change this behavior using the :option:`--encoding` option:
 
 * To keep the automatic analysis and use a different fallback encoding specify
-  for example ``--encoding=automatic;iso-8859-15``.
+  for example :option:`--encoding=automatic;iso-8859-15 <--encoding>`.
 * To use an automatic detection based on heuristic, use
-  ``--encoding=chardet``. For this to work, the
+  :option:`--encoding=chardet <--encoding>`. For this to work, the
   `chardet <https://pypi.python.org/pypi/chardet>`_ package must be installed,
 * To use a specific encoding (for all files analyzed), use for example
-  ``--encoding=iso-8859-15``.
+  :option:`--encoding=iso-8859-15 <--encoding>`.
 
 
 Pseudo languages
@@ -117,9 +158,8 @@ shown is a pseudo language indicating the reason:
   first ensures that file does not start with a BOM for UTF-8, UTF-16 or
   UTF-32 (which indicates text files). After that it checks for zero bytes
   within the initial 8192 bytes of the file.
-* ``__duplicate__`` - the source code is a bytewise identical copy of another
-  file; enable the command line option ``--duplicates`` to also count code in
-  duplicates (and gain a minor performance improvement).
+* ``__duplicate__`` - the source code duplicate as described at the command line
+  option :option:`--duplicates`.
 * ``__empty__`` - the source code is an empty file with a size of 0 bytes.
 * ``__error__`` - the source code could not be parsed e.g. due to an I/O error.
 * ``__generated__`` - the source code is generated according to the command line
@@ -127,7 +167,15 @@ shown is a pseudo language indicating the reason:
 * ``__unknown__`` - pygments does not provide a lexer to parse the source code.
 
 
-## Other information
+Other information
+-----------------
+
+.. option:: --verbose
+
+If :option:`--verbose` is specified, pygount logs detailed information about
+what it is doing.
+
+.. option:: --help
 
 To get a description of all the available command line options, run:
 
@@ -135,7 +183,9 @@ To get a description of all the available command line options, run:
 
     $ pygount --help
 
-To get the version number, run:
+.. option:: --version
+
+To get pygount's current version number, run:
 
 .. code-block:: bash
 
diff --git a/pygount/analysis.py b/pygount/analysis.py
index 3f3e23f..9128297 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 Optional, List, Dict
+from typing import Dict, Generator, List, Optional, Pattern, Sequence, Set, Tuple
 
 import pygments.lexer
 import pygments.lexers
@@ -22,8 +22,10 @@ import pygments.util
 import pygount.common
 import pygount.lexers
 import pygount.xmldialect
+from pygount.common import deprecated
 
 # Attempt to import chardet.
+
 try:
     import chardet.universaldetector
 
@@ -41,6 +43,10 @@ DEFAULT_FOLDER_PATTERNS_TO_SKIP_TEXT = ", ".join(
 )
 
 
+#: Pygments token type; we need to define our own type because pygments' ``_TokenType`` is internal.
+TokenType = type(pygments.token.Token)
+
+
 class SourceState(Enum):
     """
     Possible values for :py:attr:`SourceAnalysis.state`.
@@ -130,9 +136,56 @@ for _oracle_suffix in ("pck", "pkb", "pks", "pls"):
     _SUFFIX_TO_FALLBACK_LEXER_MAP[_oracle_suffix] = pygments.lexers.get_lexer_by_name("plpgsql")
 
 
+class DuplicatePool:
+    """
+    A pool that collects information about potential duplicate files.
+    """
+
+    def __init__(self):
+        self._size_to_paths_map = {}
+        self._size_and_hash_to_path_map = {}
+
+    @staticmethod
+    def _hash_for(path_to_hash):
+        buffer_size = 1024 * 1024
+        md5_hash = hashlib.md5()
+        with open(path_to_hash, "rb", buffer_size) as file_to_hash:
+            data = file_to_hash.read(buffer_size)
+            while len(data) >= 1:
+                md5_hash.update(data)
+                data = file_to_hash.read(buffer_size)
+        return md5_hash.digest()
+
+    def duplicate_path(self, source_path: str) -> Optional[str]:
+        """
+        Path to a duplicate for ``source_path`` or ``None`` if no duplicate exists.
+
+        Internally information is stored to identify possible future duplicates of
+        ``source_path``.
+        """
+        result = None
+        source_size = os.path.getsize(source_path)
+        paths_with_same_size = self._size_to_paths_map.get(source_size)
+        if paths_with_same_size is None:
+            self._size_to_paths_map[source_size] = [source_path]
+        else:
+            source_hash = DuplicatePool._hash_for(source_path)
+            if len(paths_with_same_size) == 1:
+                # Retrofit the initial path with the same size and its hash.
+                initial_path_with_same_size = paths_with_same_size[0]
+                initial_hash = DuplicatePool._hash_for(initial_path_with_same_size)
+                self._size_and_hash_to_path_map[(source_size, initial_hash)] = initial_path_with_same_size
+            result = self._size_and_hash_to_path_map.get((source_size, source_hash))
+            self._size_and_hash_to_path_map[(source_size, source_hash)] = source_path
+        return result
+
+
 class SourceAnalysis:
     """
-    Results of a source analysis derived by :py:func:`source_analysis`.
+    Results from analyzing a source path.
+
+    Prefer the factory methods :py:meth:`from_file()` and :py:meth:`from_state` to
+    calling the constructor.
     """
 
     def __init__(
@@ -147,6 +200,7 @@ class SourceAnalysis:
         state: SourceState,
         state_info: Optional[str] = None,
     ):
+        SourceAnalysis._check_state_info(state, state_info)
         self._path = path
         self._language = language
         self._group = group
@@ -157,6 +211,133 @@ class SourceAnalysis:
         self._state = state
         self._state_info = state_info
 
+    @staticmethod
+    def from_state(
+        source_path: str, group: str, state: SourceState, state_info: Optional[str] = None
+    ) -> "SourceAnalysis":
+        """
+        Factory method to create a :py:class:`SourceAnalysis` with all counts
+        set to 0 and everything else according to the specified parameters.
+        """
+        assert source_path is not None
+        assert group is not None
+        assert state != SourceState.analyzed, "use from() for analyzable sources"
+        SourceAnalysis._check_state_info(state, state_info)
+        return SourceAnalysis(
+            path=source_path,
+            language="__{0}__".format(state.name),
+            group=group,
+            code=0,
+            documentation=0,
+            empty=0,
+            string=0,
+            state=state,
+            state_info=state_info,
+        )
+
+    @staticmethod
+    def _check_state_info(state: SourceState, state_info: Optional[str]):
+        states_that_require_state_info = [SourceState.duplicate, SourceState.error, SourceState.generated]
+        assert (state in states_that_require_state_info) == (state_info is not None), (
+            "state=%s and state_info=%s but state_info must be specified for the following states: %s"
+            % (state, state_info, states_that_require_state_info)
+        )
+
+    @staticmethod
+    def from_file(
+        source_path: str,
+        group: str,
+        encoding: str = "automatic",
+        fallback_encoding: str = "cp1252",
+        generated_regexes=pygount.common.regexes_from(DEFAULT_GENERATED_PATTERNS_TEXT),
+        duplicate_pool: Optional[DuplicatePool] = None,
+    ) -> "SourceAnalysis":
+        """
+        Factory method to create a :py:class:`SourceAnalysis` by analyzing
+        the source code in ``source_path``.
+
+        :param source_path: path to source code to analyze
+        :param group: name of a logical group the sourc code belongs to, e.g. a
+          package.
+        :param encoding: encoding according to :func:`encoding_for`
+        :param fallback_encoding: fallback encoding according to
+          :func:`encoding_for`
+        :param generated_regexes: list of regular expression that if found within the first few lines
+          if a source code identify is as generated source code for which SLOC should not be counted
+        :param duplicate_pool: a :class:`DuplicatePool` where information about possible duplicates is
+          collected, or ``None`` if possible duplicates should be counted multiple times.
+        """
+        assert encoding is not None
+        assert generated_regexes is not None
+
+        result = None
+        lexer = None
+        source_code = None
+        source_size = os.path.getsize(source_path)
+        if source_size == 0:
+            _log.info("%s: is empty", source_path)
+            result = SourceAnalysis.from_state(source_path, group, SourceState.empty)
+        elif is_binary_file(source_path):
+            _log.info("%s: is binary", source_path)
+            result = SourceAnalysis.from_state(source_path, group, SourceState.binary)
+        elif not has_lexer(source_path):
+            _log.info("%s: unknown language", source_path)
+            result = SourceAnalysis.from_state(source_path, group, SourceState.unknown)
+        elif duplicate_pool is not None:
+            duplicate_path = duplicate_pool.duplicate_path(source_path)
+            if duplicate_path is not None:
+                _log.info("%s: is a duplicate of %s", source_path, duplicate_path)
+                result = SourceAnalysis.from_state(source_path, group, SourceState.duplicate, duplicate_path)
+        if result is None:
+            if encoding in ("automatic", "chardet"):
+                encoding = encoding_for(source_path, encoding, fallback_encoding)
+            try:
+                with open(source_path, "r", encoding=encoding) as source_file:
+                    source_code = source_file.read()
+            except (LookupError, OSError, UnicodeError) as error:
+                _log.warning("cannot read %s using encoding %s: %s", source_path, encoding, error)
+                result = SourceAnalysis.from_state(source_path, group, SourceState.error, error)
+            if result is None:
+                lexer = guess_lexer(source_path, source_code)
+                assert lexer is not None
+        if (result is None) and (len(generated_regexes) != 0):
+            number_line_and_regex = matching_number_line_and_regex(pygount.common.lines(source_code), generated_regexes)
+            if number_line_and_regex is not None:
+                number, _, regex = number_line_and_regex
+                message = "line {0} matches {1}".format(number, regex)
+                _log.info("%s: is generated code because %s", source_path, message)
+                result = SourceAnalysis.from_state(source_path, group, SourceState.generated, message)
+        if result is None:
+            assert lexer is not None
+            assert source_code is not None
+            language = lexer.name
+            if ("xml" in language.lower()) or (language == "Genshi"):
+                dialect = pygount.xmldialect.xml_dialect(source_path, source_code)
+                if dialect is not None:
+                    language = dialect
+            _log.info("%s: analyze as %s using encoding %s", source_path, language, encoding)
+            mark_to_count_map = {"c": 0, "d": 0, "e": 0, "s": 0}
+            for line_parts in _line_parts(lexer, source_code):
+                mark_to_increment = "e"
+                for mark_to_check in ("d", "s", "c"):
+                    if mark_to_check in line_parts:
+                        mark_to_increment = mark_to_check
+                mark_to_count_map[mark_to_increment] += 1
+            result = SourceAnalysis(
+                path=source_path,
+                language=language,
+                group=group,
+                code=mark_to_count_map["c"],
+                documentation=mark_to_count_map["d"],
+                empty=mark_to_count_map["e"],
+                string=mark_to_count_map["s"],
+                state=SourceState.analyzed,
+                state_info=None,
+            )
+
+        assert result is not None
+        return result
+
     @property
     def path(self) -> str:
         return self._path
@@ -166,7 +347,7 @@ class SourceAnalysis:
         return self._language
 
     @property
-    def group(self) -> int:
+    def group(self) -> str:
         return self._group
 
     @property
@@ -186,11 +367,17 @@ class SourceAnalysis:
         return self._string
 
     @property
-    def state(self) -> str:
+    def state(self) -> SourceState:
+        """
+        The state of the analysis after parsing the source file.
+        """
         return self._state
 
     @property
-    def state_info(self) -> str:
+    def state_info(self) -> Optional[str]:
+        """
+        Possible additional information about :py:attr:`state`.
+        """
         return self._state_info
 
     @property
@@ -215,6 +402,10 @@ class SourceAnalysis:
 
 
 class SourceScanner:
+    """
+    Scanner for source code files matching certain conditions.
+    """
+
     def __init__(
         self,
         source_patterns,
@@ -240,7 +431,7 @@ class SourceScanner:
         return self._folder_regexps_to_skip
 
     @folder_regexps_to_skip.setter
-    def set_folder_pattern_to_skip(self, regexps_or_pattern_text):
+    def folder_regexps_to_skip(self, regexps_or_pattern_text):
         self._folder_regexps_to_skip.append = pygount.common.regexes_from(
             regexps_or_pattern_text, self.folder_regexps_to_skip
         )
@@ -250,7 +441,7 @@ class SourceScanner:
         return self._name_regexps_to_skip
 
     @name_regexps_to_skip.setter
-    def set_name_regexps_to_skip(self, regexps_or_pattern_text):
+    def name_regexps_to_skip(self, regexps_or_pattern_text):
         self._name_regexp_to_skip = pygount.common.regexes_from(regexps_or_pattern_text, self.name_regexps_to_skip)
 
     def _is_path_to_skip(self, name, is_folder):
@@ -308,7 +499,10 @@ class SourceScanner:
         result = sorted(set(result))
         return result
 
-    def source_paths(self):
+    def source_paths(self) -> Generator[str, None, None]:
+        """
+        Paths to source code files matching all the conditions for this scanner.
+        """
         source_paths_and_groups_to_analyze = self._source_paths_and_groups_to_analyze(self.source_patterns)
 
         for source_path, group in source_paths_and_groups_to_analyze:
@@ -320,56 +514,14 @@ class SourceScanner:
                 _log.info("skip due to suffix: %s", source_path)
 
 
-class DuplicatePool:
-    """
-    A pool that collects information about potential duplicate files.
-    """
-
-    def __init__(self):
-        self._size_to_paths_map = {}
-        self._size_and_hash_to_path_map = {}
-
-    @staticmethod
-    def _hash_for(path_to_hash):
-        buffer_size = 1024 * 1024
-        md5_hash = hashlib.md5()
-        with open(path_to_hash, "rb", buffer_size) as file_to_hash:
-            data = file_to_hash.read(buffer_size)
-            while len(data) >= 1:
-                md5_hash.update(data)
-                data = file_to_hash.read(buffer_size)
-        return md5_hash.digest()
-
-    def duplicate_path(self, source_path: str) -> Optional[str]:
-        """
-        Path to a duplicate for ``source_path`` or ``None`` if no duplicate exists.
-
-        Internally information is stored to identify possible future duplicates of
-        ``source_path``.
-        """
-        result = None
-        source_size = os.path.getsize(source_path)
-        paths_with_same_size = self._size_to_paths_map.get(source_size)
-        if paths_with_same_size is None:
-            self._size_to_paths_map[source_size] = [source_path]
-        else:
-            source_hash = DuplicatePool._hash_for(source_path)
-            if len(paths_with_same_size) == 1:
-                # Retrofit the initial path with the same size and its hash.
-                initial_path_with_same_size = paths_with_same_size[0]
-                initial_hash = DuplicatePool._hash_for(initial_path_with_same_size)
-                self._size_and_hash_to_path_map[(source_size, initial_hash)] = initial_path_with_same_size
-            result = self._size_and_hash_to_path_map.get((source_size, source_hash))
-            self._size_and_hash_to_path_map[(source_size, source_hash)] = source_path
-        return result
-
-
-_LANGUAGE_TO_WHITE_WORDS_MAP = {"batchfile": ["@"], "python": ["pass"], "sql": ["begin", "end"]}
+_LANGUAGE_TO_WHITE_WORDS_MAP = {"batchfile": {"@"}, "python": {"pass"}, "sql": {"begin", "end"}}
 for _language in _LANGUAGE_TO_WHITE_WORDS_MAP.keys():
     assert _language.islower()
 
 
-def matching_number_line_and_regex(source_lines, generated_regexes, max_line_count=15):
+def matching_number_line_and_regex(
+    source_lines: Sequence[str], generated_regexes: Sequence[Pattern], max_line_count: int = 15
+) -> Optional[Tuple[int, str, Pattern]]:
     """
     The first line and its number (starting with 0) in the source code that
     indicated that the source code is generated.
@@ -388,11 +540,13 @@ def matching_number_line_and_regex(source_lines, generated_regexes, max_line_cou
         if matching_regex.match(line)
     )
     possible_first_matching_number_line_and_regexp = list(itertools.islice(matching_number_line_and_regexps, 1))
-    result = (possible_first_matching_number_line_and_regexp + [None])[0]
+    result = (
+        possible_first_matching_number_line_and_regexp[0] if possible_first_matching_number_line_and_regexp else None
+    )
     return result
 
 
-def white_characters(language_id) -> str:
+def white_characters(language_id: str) -> str:
     """
     Characters that count as white space if they are the only characters in a
     line.
@@ -402,7 +556,7 @@ def white_characters(language_id) -> str:
     return "(),:;[]{}"
 
 
-def white_code_words(language_id) -> Dict[str, List[str]]:
+def white_code_words(language_id: str) -> Dict[str, List[str]]:
     """
     Words that do not count as code if it is the only word in a line.
     """
@@ -411,7 +565,7 @@ def white_code_words(language_id) -> Dict[str, List[str]]:
     return _LANGUAGE_TO_WHITE_WORDS_MAP.get(language_id, set())
 
 
-def _delined_tokens(tokens):
+def _delined_tokens(tokens: Sequence[Tuple[TokenType, str]]) -> Generator[TokenType, None, None]:
     for token_type, token_text in tokens:
         newline_index = token_text.find("\n")
         while newline_index != -1:
@@ -422,7 +576,7 @@ def _delined_tokens(tokens):
             yield token_type, token_text
 
 
-def _pythonized_comments(tokens):
+def _pythonized_comments(tokens: Sequence[Tuple[TokenType, str]]) -> Generator[TokenType, None, None]:
     """
     Similar to tokens but converts strings after a colon (:) to comments.
     """
@@ -439,7 +593,7 @@ def _pythonized_comments(tokens):
         yield token_type, token_text
 
 
-def _line_parts(lexer, text):
+def _line_parts(lexer: pygments.lexer.Lexer, text: str) -> Generator[Set[str], None, None]:
     line_marks = set()
     tokens = _delined_tokens(lexer.get_tokens(text))
     if lexer.name == "Python":
@@ -470,10 +624,13 @@ def encoding_for(source_path: str, encoding: str = "automatic", fallback_encodin
     The algorithm used is:
 
     * If ``encoding`` is ``'automatic``, attempt the following:
+
       1. Check BOM for UTF-8, UTF-16 and UTF-32.
       2. Look for XML prolog or magic heading like ``# -*- coding: cp1252 -*-``
       3. Read the file using UTF-8.
-      4. If all this fails, use assume the ``fallback_encoding``.
+      4. If all this fails, use the ``fallback_encoding`` and ignore any
+         further encoding errors.
+
     * If ``encoding`` is ``'chardet`` use :mod:`chardet` to obtain the encoding.
     * For any other ``encoding`` simply use the specified value.
     """
@@ -545,28 +702,16 @@ def encoding_for(source_path: str, encoding: str = "automatic", fallback_encodin
     return result
 
 
+@deprecated("use {0}.{1}".format(SourceAnalysis.__name__, SourceAnalysis.from_state.__name__))
 def pseudo_source_analysis(source_path, group, state, state_info=None):
-    assert source_path is not None
-    assert group is not None
-    assert isinstance(state, SourceState)
-    return SourceAnalysis(
-        path=source_path,
-        language="__" + state.name + "__",
-        group=group,
-        code=0,
-        documentation=0,
-        empty=0,
-        string=0,
-        state=state,
-        state_info=state_info,
-    )
+    return SourceAnalysis.from_state(source_path, group, state, state_info)
 
 
 #: BOMs to indicate that a file is a text file even if it contains zero bytes.
 _TEXT_BOMS = (codecs.BOM_UTF16_BE, codecs.BOM_UTF16_LE, codecs.BOM_UTF32_BE, codecs.BOM_UTF32_LE, codecs.BOM_UTF8)
 
 
-def is_binary_file(source_path):
+def is_binary_file(source_path: str) -> bool:
     with open(source_path, "rb") as source_file:
         initial_bytes = source_file.read(8192)
     return not any(initial_bytes.startswith(bom) for bom in _TEXT_BOMS) and b"\0" in initial_bytes
@@ -576,7 +721,7 @@ def is_plain_text(source_path):
     return _PLAIN_TEXT_NAME_REGEX.match(os.path.basename(source_path))
 
 
-def has_lexer(source_path):
+def has_lexer(source_path: str) -> bool:
     """
     Initial quick check if there is a lexer for ``source_path``. This removes
     the need for calling :py:func:`pygments.lexers.guess_lexer_for_filename()`
@@ -601,6 +746,7 @@ def guess_lexer(source_path: str, text: str) -> pygments.lexer.Lexer:
     return result
 
 
+@deprecated("use {0}.{1}".format(SourceAnalysis.__name__, SourceAnalysis.from_file.__name__))
 def source_analysis(
     source_path,
     group,
@@ -609,87 +755,4 @@ def source_analysis(
     generated_regexes=pygount.common.regexes_from(DEFAULT_GENERATED_PATTERNS_TEXT),
     duplicate_pool: Optional[DuplicatePool] = None,
 ):
-    """
-    Analysis for line counts in source code stored in ``source_path``.
-
-    :param source_path:
-    :param group: name of a logical group the sourc code belongs to, e.g. a
-      package.
-    :param encoding: encoding according to :func:`encoding_for`
-    :param fallback_encoding: fallback encoding according to
-      :func:`encoding_for`
-    :param generated_regexes: list of regular expression that if found within the first few lines
-      if a source code identify is as generated source code for which SLOC should not be counted
-    :param duplicate_pool:
-    :return: a :class:`SourceAnalysis`
-    """
-    assert encoding is not None
-    assert generated_regexes is not None
-
-    result = None
-    lexer = None
-    source_code = None
-    source_size = os.path.getsize(source_path)
-    if source_size == 0:
-        _log.info("%s: is empty", source_path)
-        result = pseudo_source_analysis(source_path, group, SourceState.empty)
-    elif is_binary_file(source_path):
-        _log.info("%s: is binary", source_path)
-        result = pseudo_source_analysis(source_path, group, SourceState.binary)
-    elif not has_lexer(source_path):
-        _log.info("%s: unknown language", source_path)
-        result = pseudo_source_analysis(source_path, group, SourceState.unknown)
-    elif duplicate_pool is not None:
-        duplicate_path = duplicate_pool.duplicate_path(source_path)
-        if duplicate_path is not None:
-            _log.info("%s: is a duplicate of %s", source_path, duplicate_path)
-            result = pseudo_source_analysis(source_path, group, SourceState.duplicate, duplicate_path)
-    if result is None:
-        if encoding in ("automatic", "chardet"):
-            encoding = encoding_for(source_path, encoding, fallback_encoding)
-        try:
-            with open(source_path, "r", encoding=encoding) as source_file:
-                source_code = source_file.read()
-        except (LookupError, OSError, UnicodeError) as error:
-            _log.warning("cannot read %s using encoding %s: %s", source_path, encoding, error)
-            result = pseudo_source_analysis(source_path, group, SourceState.error, error)
-        if result is None:
-            lexer = guess_lexer(source_path, source_code)
-            assert lexer is not None
-    if (result is None) and (len(generated_regexes) != 0):
-        number_line_and_regex = matching_number_line_and_regex(pygount.common.lines(source_code), generated_regexes)
-        if number_line_and_regex is not None:
-            number, _, regex = number_line_and_regex
-            message = "line {0} matches {1}".format(number, regex)
-            _log.info("%s: is generated code because %s", source_path, message)
-            result = pseudo_source_analysis(source_path, group, SourceState.generated, message)
-    if result is None:
-        assert lexer is not None
-        assert source_code is not None
-        language = lexer.name
-        if ("xml" in language.lower()) or (language == "Genshi"):
-            dialect = pygount.xmldialect.xml_dialect(source_path, source_code)
-            if dialect is not None:
-                language = dialect
-        _log.info("%s: analyze as %s using encoding %s", source_path, language, encoding)
-        mark_to_count_map = {"c": 0, "d": 0, "e": 0, "s": 0}
-        for line_parts in _line_parts(lexer, source_code):
-            mark_to_increment = "e"
-            for mark_to_check in ("d", "s", "c"):
-                if mark_to_check in line_parts:
-                    mark_to_increment = mark_to_check
-            mark_to_count_map[mark_to_increment] += 1
-        result = SourceAnalysis(
-            path=source_path,
-            language=language,
-            group=group,
-            code=mark_to_count_map["c"],
-            documentation=mark_to_count_map["d"],
-            empty=mark_to_count_map["e"],
-            string=mark_to_count_map["s"],
-            state=SourceState.analyzed,
-            state_info=None,
-        )
-
-    assert result is not None
-    return result
+    return SourceAnalysis.from_file(source_path, group, encoding, fallback_encoding, generated_regexes, duplicate_pool)
diff --git a/pygount/command.py b/pygount/command.py
index 974eb61..7af75eb 100644
--- a/pygount/command.py
+++ b/pygount/command.py
@@ -335,7 +335,7 @@ class Command:
             writer_class = _OUTPUT_FORMAT_TO_WRITER_CLASS_MAP[self.output_format]
             with writer_class(target_file) as writer:
                 for source_path, group in source_paths_and_groups_to_analyze:
-                    statistics = pygount.analysis.source_analysis(
+                    statistics = pygount.analysis.SourceAnalysis.from_file(
                         source_path,
                         group,
                         self.default_encoding,
diff --git a/pygount/common.py b/pygount/common.py
index 7226b38..5ba1c51 100644
--- a/pygount/common.py
+++ b/pygount/common.py
@@ -4,10 +4,14 @@ Common classes and functions for pygount.
 # Copyright (c) 2016-2020, Thomas Aglassinger.
 # All rights reserved. Distributed under the BSD License.
 import fnmatch
+import functools
+import inspect
 import re
+import warnings
+from typing import Generator, List, Optional, Pattern, Sequence, Union
 
 
-__version__ = "1.2.0"
+__version__ = "1.2.1"
 
 
 #: Pseudo pattern to indicate that the remaining pattern are an addition to the default patterns.
@@ -42,7 +46,7 @@ class OptionError(Error):
         return self.option_error_message
 
 
-def as_list(items_or_text):
+def as_list(items_or_text: Union[str, Sequence[str]]) -> List[str]:
     if isinstance(items_or_text, str):
         # TODO: Allow to specify comma (,) in text using '[,]'.
         result = [item.strip() for item in items_or_text.split(",") if item.strip() != ""]
@@ -51,7 +55,7 @@ def as_list(items_or_text):
     return result
 
 
-def regex_from(pattern, is_shell_pattern=False):
+def regex_from(pattern: Union[str, Pattern], is_shell_pattern=False) -> Pattern:
     assert pattern is not None
     if isinstance(pattern, str):
         if is_shell_pattern:
@@ -63,7 +67,11 @@ def regex_from(pattern, is_shell_pattern=False):
     return result
 
 
-def regexes_from(patterns_text, default_patterns_text=None, source=None):
+def regexes_from(
+    patterns_text: Union[str, Sequence[str]],
+    default_patterns_text: Optional[Union[str, Sequence[str]]] = None,
+    source: Optional[str] = None,
+) -> List[Pattern]:
     assert patterns_text is not None
 
     result = []
@@ -99,7 +107,7 @@ def regexes_from(patterns_text, default_patterns_text=None, source=None):
     return result
 
 
-def lines(text):
+def lines(text: str) -> Generator[str, None, None]:
     """
     Generator function to yield lines (delimited with ``'\n'``) stored in
     ``text``. This is useful when a regular expression should only match on a
@@ -116,3 +124,67 @@ def lines(text):
     last_line = text[previous_newline_index:]
     if last_line != "":
         yield last_line
+
+
+def deprecated(reason: Optional[str]):  # pragma: no cover
+    """
+    Decorator to mark functions as deprecated and log a warning in case it is called.
+
+    Source: https://stackoverflow.com/questions/2536307/decorators-in-the-python-standard-lib-deprecated-specifically
+    """
+
+    if isinstance(reason, str):
+
+        # The @deprecated is used with a 'reason'.
+        #
+        # .. code-block:: python
+        #
+        #    @deprecated("please, use another function")
+        #    def old_function(x, y):
+        #      pass
+
+        def decorator(func1):
+            if inspect.isclass(func1):
+                fmt1 = "Call to deprecated class {name} ({reason})."
+            else:
+                fmt1 = "Call to deprecated function {name} ({reason})."
+
+            @functools.wraps(func1)
+            def new_func1(*args, **kwargs):
+                warnings.simplefilter("always", DeprecationWarning)
+                warnings.warn(
+                    fmt1.format(name=func1.__name__, reason=reason), category=DeprecationWarning, stacklevel=2
+                )
+                warnings.simplefilter("default", DeprecationWarning)
+                return func1(*args, **kwargs)
+
+            return new_func1
+
+        return decorator
+    elif inspect.isclass(reason) or inspect.isfunction(reason):
+
+        # The @deprecated is used without any 'reason'.
+        #
+        # .. code-block:: python
+        #
+        #    @deprecated
+        #    def old_function(x, y):
+        #      pass
+
+        func2 = reason
+
+        if inspect.isclass(func2):
+            fmt2 = "Call to deprecated class {name}."
+        else:
+            fmt2 = "Call to deprecated function {name}."
+
+        @functools.wraps(func2)
+        def new_func2(*args, **kwargs):
+            warnings.simplefilter("always", DeprecationWarning)
+            warnings.warn(fmt2.format(name=func2.__name__), category=DeprecationWarning, stacklevel=2)
+            warnings.simplefilter("default", DeprecationWarning)
+            return func2(*args, **kwargs)
+
+        return new_func2
+    else:
+        raise TypeError(repr(type(reason)))
diff --git a/pygount/summary.py b/pygount/summary.py
index 5e74838..8d7061c 100644
--- a/pygount/summary.py
+++ b/pygount/summary.py
@@ -5,6 +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 .analysis import SourceAnalysis
 
@@ -19,7 +20,7 @@ class LanguageSummary:
     """
 
     def __init__(self, language: str):
-        self.language = language
+        self._language = language
         self.code = 0
         self.documentation = 0
         self.file_count = 0
@@ -27,6 +28,10 @@ class LanguageSummary:
         self.string = 0
         self.is_pseudo_language = _PSEUDO_LANGUAGE_REGEX.match(self.language) is not None
 
+    @property
+    def language(self) -> str:
+        return self._language
+
     def sort_key(self):
         return self.code, self.documentation, self.string, self.empty, self.language
 
@@ -36,7 +41,13 @@ class LanguageSummary:
     def __lt__(self, other):
         return self.sort_key() < other.sort_key()
 
-    def add(self, source_analysis: SourceAnalysis):
+    def add(self, source_analysis: SourceAnalysis) -> None:
+        """
+        Add counts from ``source_analysis`` to total counts for this language.
+        """
+        assert source_analysis is not None
+        assert source_analysis.language == self.language
+
         self.file_count += 1
         if source_analysis.is_countable:
             self.code += source_analysis.code
@@ -60,7 +71,7 @@ class ProjectSummary:
     """
 
     def __init__(self):
-        self.language_to_language_summary_map = {}
+        self._language_to_language_summary_map = {}
         self.total_code_count = 0
         self.total_documentation_count = 0
         self.total_empty_count = 0
@@ -68,7 +79,17 @@ class ProjectSummary:
         self.total_file_count = 0
         self.total_line_count = 0
 
-    def add(self, source_analysis: SourceAnalysis):
+    @property
+    def language_to_language_summary_map(self) -> Dict[str, LanguageSummary]:
+        """
+        A map containing summarized counts for each language added with :py:meth:`add()` so far.
+        """
+        return self._language_to_language_summary_map
+
+    def add(self, source_analysis: SourceAnalysis) -> None:
+        """
+        Add counts from ``source_analysis`` to total counts.
+        """
         self.total_file_count += 1
         language_summary = self.language_to_language_summary_map.get(source_analysis.language)
         if language_summary is None:
diff --git a/pygount/write.py b/pygount/write.py
index 4d1030f..850ebf6 100644
--- a/pygount/write.py
+++ b/pygount/write.py
@@ -1,5 +1,5 @@
 """
-Writers to store the resuls of a pygount analysis.
+Writers to store the results of a pygount analysis.
 """
 # Copyright (c) 2016-2020, Thomas Aglassinger.
 # All rights reserved. Distributed under the BSD License.
diff --git a/setup.py b/setup.py
index a01f6d1..7dabcd0 100644
--- a/setup.py
+++ b/setup.py
@@ -6,14 +6,14 @@ Developer cheat sheet
 
 Tag a release (simply replace ``1.x.x`` with the current version number)::
 
-  $ git tag -a -m "Tagged version 1.x.x" v1.x.x
+  $ git tag -a -m "Tagged version 1.x.x." v1.x.x
   $ git push --tags
 
 Upload release to PyPI::
 
   $ python3 setup.py bdist_wheel
   $ twine check dist/*.whl
-  $ twine upload dist/*.whl
+  $ twine upload --config-file ~/.pypyrc dist/*.whl
 """
 # Copyright (c) 2016-2020, Thomas Aglassinger.
 # All rights reserved. Distributed under the BSD License.
@@ -35,7 +35,10 @@ setup(
     long_description=long_description,
     long_description_content_type="text/markdown",
     url="https://github.com/roskakori/pygount",
-    project_urls={"Issue Tracker": "https://github.com/roskakori/pygount/issues"},
+    project_urls={
+        "Documentation": "https://pygount.readthedocs.io/",
+        "Issue Tracker": "https://github.com/roskakori/pygount/issues",
+    },
     author="Thomas Aglassinger",
     author_email="roskakori@users.sourceforge.net",
     license="BSD",


Test patch:
diff --git a/tests/test_analysis.py b/tests/test_analysis.py
index fc9ae00..b0156ce 100644
--- a/tests/test_analysis.py
+++ b/tests/test_analysis.py
@@ -103,7 +103,7 @@ class AnalysisTest(unittest.TestCase):
 class FileAnalysisTest(TempFolderTest):
     def test_can_analyze_encoding_error(self):
         test_path = self.create_temp_file("encoding_error.py", 'print("\N{EURO SIGN}")', encoding="cp1252")
-        source_analysis = analysis.source_analysis(test_path, "test", encoding="utf-8")
+        source_analysis = analysis.SourceAnalysis.from_file(test_path, "test", encoding="utf-8")
         assert source_analysis.language == "__error__"
         assert source_analysis.state == analysis.SourceState.error
         assert "0x80" in str(source_analysis.state_info)
@@ -113,7 +113,7 @@ class FileAnalysisTest(TempFolderTest):
             "test_can_detect_silent_dos_batch_remarks.bat",
             ["rem normal comment", "@rem silent comment", "echo some code"],
         )
-        source_analysis = analysis.source_analysis(test_bat_path, "test", encoding="utf-8")
+        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
@@ -124,7 +124,7 @@ class FileAnalysisTest(TempFolderTest):
         )
         no_such_encoding = analysis.encoding_for(test_path)
         assert no_such_encoding == "no_such_encoding"
-        source_analysis = analysis.source_analysis(test_path, "test", encoding=no_such_encoding)
+        source_analysis = analysis.SourceAnalysis.from_file(test_path, "test", encoding=no_such_encoding)
         assert source_analysis.language == "__error__"
         assert source_analysis.state == analysis.SourceState.error
         assert "unknown encoding" in str(source_analysis.state_info)
@@ -133,7 +133,7 @@ class FileAnalysisTest(TempFolderTest):
         test_oracle_sql_path = self.create_temp_file(
             "some_oracle_sql.pls", ["-- Oracle SQL example using an obscure suffix.", "select *", "from some_table;"],
         )
-        source_analysis = analysis.source_analysis(test_oracle_sql_path, "test", encoding="utf-8")
+        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
@@ -142,30 +142,30 @@ class FileAnalysisTest(TempFolderTest):
         test_fex_path = self.create_temp_file(
             "some.fex", ["-* comment", "-type some text", "table file some print * end;"]
         )
-        source_analysis = analysis.source_analysis(test_fex_path, "test", encoding="utf-8")
+        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
 
     def test_can_analyze_xml_dialect(self):
         build_xml_path = self.create_temp_file("build.xml", EXAMPLE_ANT_CODE)
-        source_analysis = analysis.source_analysis(build_xml_path, "test")
+        source_analysis = analysis.SourceAnalysis.from_file(build_xml_path, "test")
         assert source_analysis.state == analysis.SourceState.analyzed
         assert source_analysis.language == "Ant"
 
     def test_can_analyze_unknown_language(self):
         unknown_language_path = self.create_temp_file("some.unknown_language", ["some", "lines", "of", "text"])
-        source_analysis = analysis.source_analysis(unknown_language_path, "test")
+        source_analysis = analysis.SourceAnalysis.from_file(unknown_language_path, "test")
         assert source_analysis.state == analysis.SourceState.unknown
 
     def test_can_detect_binary_source_code(self):
         binary_path = self.create_temp_binary_file("some_django.mo", b"hello\0world!")
-        source_analysis = analysis.source_analysis(binary_path, "test", encoding="utf-8")
+        source_analysis = analysis.SourceAnalysis.from_file(binary_path, "test", encoding="utf-8")
         assert source_analysis.state == analysis.SourceState.binary
         assert source_analysis.code == 0
 
 
-def test_can_repr_source_analysis():
+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', "
@@ -175,14 +175,14 @@ def test_can_repr_source_analysis():
     assert repr(source_analysis) == str(source_analysis)
 
 
-def test_can_repr_empty_source_analysis():
+def test_can_repr_empty_source_analysis_from_file():
     source_analysis = analysis.SourceAnalysis("some.py", "__empty__", "some", 0, 0, 0, 0, analysis.SourceState.empty)
     expected_source_analysis_repr = "SourceAnalysis(path='some.py', language='__empty__', group='some', state=empty)"
     assert repr(source_analysis) == expected_source_analysis_repr
     assert repr(source_analysis) == str(source_analysis)
 
 
-def test_can_repr_error_source_analysis():
+def test_can_repr_error_source_analysis_from_file():
     source_analysis = analysis.SourceAnalysis(
         "some.py", "__error__", "some", 0, 0, 0, 0, analysis.SourceState.error, "error details"
     )
@@ -320,7 +320,7 @@ class GeneratedCodeTest(TempFolderTest):
     def test_can_analyze_generated_code_with_own_pattern(self):
         lines = ["-- Generiert mit Hau-Ruck-Franz-Deutsch.", "select * from sauerkraut;"]
         generated_sql_path = self.create_temp_file("generated.sql", lines)
-        source_analysis = analysis.source_analysis(
+        source_analysis = analysis.SourceAnalysis.from_file(
             generated_sql_path, "test", generated_regexes=common.regexes_from("[regex](?i).*generiert")
         )
         assert source_analysis.state == analysis.SourceState.generated
@@ -329,7 +329,7 @@ class GeneratedCodeTest(TempFolderTest):
 class SizeTest(TempFolderTest):
     def test_can_detect_empty_source_code(self):
         empty_py_path = self.create_temp_binary_file("empty.py", b"")
-        source_analysis = analysis.source_analysis(empty_py_path, "test", encoding="utf-8")
+        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
 
@@ -337,7 +337,7 @@ class SizeTest(TempFolderTest):
 def test_can_analyze_project_markdown_files():
     project_root_folder = os.path.dirname(PYGOUNT_PROJECT_FOLDER)
     for text_path in glob.glob(os.path.join(project_root_folder, "*.md")):
-        source_analysis = analysis.source_analysis(text_path, "test")
+        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
@@ -359,6 +359,20 @@ def test_has_no_duplicate_in_pygount_source():
             assert duplicate_path is None, "{0} must not be duplicate of {1}".format(source_path, duplicate_path)
 
 
+def test_can_match_deprecated_functions():
+    source_path = __file__
+    group = "some"
+    assert str(analysis.source_analysis(source_path, group)) == str(
+        analysis.SourceAnalysis.from_file(source_path, group)
+    )
+
+    missing_path = "missing.py"
+    reason = "missing"
+    assert str(analysis.pseudo_source_analysis(missing_path, group, analysis.SourceState.error, reason)) == str(
+        analysis.SourceAnalysis.from_state(missing_path, group, analysis.SourceState.error, reason)
+    )
+
+
 class DuplicatePoolTest(TempFolderTest):
     def test_can_distinguish_different_files(self):
         some_path = self.create_temp_file(__name__ + "_some", "some")
diff --git a/tests/test_summary.py b/tests/test_summary.py
index ce20e4c..f97b2ff 100644
--- a/tests/test_summary.py
+++ b/tests/test_summary.py
@@ -62,7 +62,7 @@ def test_can_summarize_project_with_multiple_files_of_different_languages():
 def test_can_summarize_project_with_pseudo_languages():
     source_analyses = (
         SourceAnalysis("empty.py", "__empty__", "some", 0, 0, 0, 0, SourceState.empty),
-        SourceAnalysis("generated.py", "__generated__", "some", 1, 2, 3, 4, SourceState.generated),
+        SourceAnalysis("generated.py", "__generated__", "some", 1, 2, 3, 4, SourceState.generated, "generated by test"),
         SourceAnalysis("binary .bin", "__binary__", "some", 0, 0, 0, 0, SourceState.binary),
     )
     expected_languages = {source_analysis.language for source_analysis in source_analyses}