├── .github
├── dependabot.yml
└── workflows
│ └── python-package.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .pre-commit-hooks.yaml
├── .readthedocs.yml
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE.txt
├── MANIFEST.in
├── README.md
├── THANKS.txt
├── docs
├── Makefile
├── _config.yml
├── api_essentials.rst
├── complete_api.rst
├── conf.py
├── index.rst
├── logo_wide.png
└── source
│ ├── docstr_coverage.rst
│ └── modules.rst
├── docstr_coverage
├── __init__.py
├── badge.py
├── cli.py
├── config_file.py
├── coverage.py
├── ignore_config.py
├── printers.py
├── result_collection.py
├── templates
│ └── flat.svg
└── visitor.py
├── pyproject.toml
├── setup.cfg
├── setup.py
└── tests
├── config_files
├── docstr_ignore.txt
├── with_ignore.yml
└── without_ignore.yml
├── excused_samples
├── fully_excused.py
└── partially_excused.py
├── extra_samples
└── private_undocumented.py
├── individual_samples
├── decorators.py
└── long_doc.py
├── sample_files
├── badges
│ ├── 0.svg
│ ├── 100.svg
│ ├── 12.svg
│ ├── 54.svg
│ ├── 71.svg
│ ├── 84.svg
│ └── 94.svg
├── subdir_a
│ ├── docstr_ignore.txt
│ ├── documented_file.py
│ ├── empty_file.py
│ ├── partly_documented_file.py
│ └── some_code_no_docs.py
└── subdir_b
│ ├── docstr_ignore.txt
│ ├── documented_file.py
│ ├── empty_file.py
│ ├── partly_documented_file.py
│ └── some_code_no_docs.py
├── test_badge.py
├── test_cli.py
├── test_config_file.py
├── test_coverage.py
├── test_housekeeping.py
└── test_result_collection.py
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "pip"
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 | ignore:
13 | - dependency-name: "click" # No explicit version specified in requirementes as of now.
14 | - dependency-name: "PyYAML" # No explicit version specified in requirements as of now.
15 | - dependency-name: "*"
16 | update-types: ["version-update:semver-patch"]
17 | - dependency-name: "black"
18 | update-types: ["version-update:semver-minor"]
19 |
--------------------------------------------------------------------------------
/.github/workflows/python-package.yml:
--------------------------------------------------------------------------------
1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
3 |
4 | name: Python package
5 |
6 | on:
7 | push:
8 | branches: [master]
9 | pull_request:
10 | branches: [master]
11 |
12 | jobs:
13 | lint:
14 | runs-on: ubuntu-latest
15 | container:
16 | image: python:3.7
17 |
18 | steps:
19 | - uses: actions/checkout@v2
20 | - name: Install dependencies
21 | run: |
22 | python -m pip install --upgrade pip
23 | pip install -e .[lint]
24 | - run: black . --check
25 | - run: flake8
26 | - run: isort docstr_coverage tests --check-only --profile black
27 |
28 | test:
29 | runs-on: ${{ matrix.os }}
30 | strategy:
31 | matrix:
32 | os: [macos-latest, windows-latest, ubuntu-latest]
33 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
34 |
35 | steps:
36 | - uses: actions/checkout@v2
37 | - name: Set up Python ${{ matrix.python-version }}
38 | uses: actions/setup-python@v2
39 | with:
40 | python-version: ${{ matrix.python-version }}
41 | - name: Install dependencies
42 | run: |
43 | python -m pip install --upgrade pip
44 | pip install -e .[test]
45 | - name: Test with pytest
46 | run: |
47 | pytest
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-Compiled Files
2 | *.py[cod]
3 |
4 | # Environments
5 | .env
6 | env/
7 | env.bak/
8 | venv/
9 | venv*/
10 |
11 |
12 | # Documentation
13 | docs/_build/
14 | docs/_static/
15 | docs/_templates/
16 |
17 | # Distribution/Packaging
18 | .Python
19 | build/
20 | dist/
21 | eggs/
22 | .eggs/
23 | lib64/
24 | parts/
25 | sdist/
26 | var/
27 | wheels/
28 | *.egg-info/
29 | pip-wheel-metadata/
30 |
31 | # Unit Tests/Coverage
32 | htmlcov/
33 | .tox/
34 | .coverage
35 | .coverage.*
36 | .cache
37 | nosetests.xml
38 | coverage.xml
39 | *.cover
40 | .hypothesis/
41 | .pytest_cache/
42 | .htmlcov/
43 |
44 | # Jupyter Notebooks
45 | .ipynb_checkpoints
46 |
47 | # Miscellaneous
48 | .idea/
49 | .DS_Store
50 | .vscode
51 |
52 | # Temporary test artifacts
53 | /tests/.docstr.yaml
54 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/ambv/black
3 | rev: 22.3.0
4 | hooks:
5 | - id: black
6 | language_version: python3.7
7 | - repo: https://gitlab.com/pycqa/flake8
8 | rev: 4.0.1
9 | hooks:
10 | - id: flake8
11 | - repo: https://github.com/pycqa/isort
12 | rev: 5.10.1
13 | hooks:
14 | - id: isort
15 | name: isort (python)
--------------------------------------------------------------------------------
/.pre-commit-hooks.yaml:
--------------------------------------------------------------------------------
1 | - id: docstr-coverage
2 | name: docstr-coverage
3 | entry: docstr-coverage
4 | require_serial: true
5 | language: python
6 | language_version: python3
7 | types_or: [cython, pyi, python]
8 | minimum_pre_commit_version: "2.9.0"
9 | always_run: true
10 | pass_filenames: false
11 |
--------------------------------------------------------------------------------
/.readthedocs.yml:
--------------------------------------------------------------------------------
1 | build:
2 | image: latest
3 |
4 | python:
5 | version: 3.6
6 | pip_install: true
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 |
2 | ## [Unreleased]
3 |
4 | ...
5 |
6 |
7 | ## [2.3.2] (2024-05-07)
8 |
9 | ### Changes
10 | - Unpin TQDM version requirement. [#142] by [MiWeiss].
11 |
12 |
13 | ## [2.3.1] (2024-02-28)
14 |
15 | ### Features
16 | - Add support for Python 3.12. [#139] by [MiWeiss].
17 |
18 | ### Deprecations
19 | - Remove support for Python 3.7. [#139] by [MiWeiss].
20 |
21 |
22 |
23 | ## [2.3.0] (2023-04-13)
24 |
25 | ### Features
26 | - Display progress bar during docstring analysis. [#106] by [MiWeiss].
27 | - Add support for analyzing async functions' docstrings. [#116] by [caffeinatedMike].
28 | - Add support for Python 3.11. [#117] by [MiWeiss].
29 |
30 | ### Deprecations
31 | - Remove support for Python 3.6. [#117] by [MiWeiss].
32 | - Deprecate `--docstr-ignore-file` flag. [#87] by [MiWeiss].
33 | - Going forward, please use the `ignore_patterns` option in your config file
34 | - The `--docstr-ignore-file` will be removed in a future release
35 |
36 |
37 |
38 | ## [2.2.0] (2022-03-17)
39 |
40 | ### Features
41 | - Add `--skip-property` flag to ignore methods with the `@property` decorator. [#86] by [MiWeiss].
42 | - Add `--include-setter` flag to check methods with the `@setter` decorator. [#86] by [MiWeiss].
43 | - Add `--include-deleter` flag to check methods with the `@deleter` decorator. [#86] by [MiWeiss].
44 |
45 |
46 |
47 | ## [2.1.1] (2021-07-02)
48 |
49 | ### Bug Fixes
50 | - Fix inconsistent pre-commit hook behavior. [#84] by [killthekitten].
51 | - This was caused by the hook being run on different sets of files
52 | - If you are experiencing issues with the pre-commit hook, please ensure that you have
53 | properly set the `paths` argument in your config file
54 |
55 | ### Changes
56 | - Don't report on fully-documented files by default. [#85] by [MiWeiss].
57 | - The new verbosity level of 4 will include fully-documented files in the report
58 |
59 |
60 |
61 | ## [2.1.0] (2021-06-25)
62 |
63 | ### Features
64 | - Add pre-commit hook. [#78] by [bjornconstructors]
65 | - *Experimental:* Expose new `analyze` method, which returns fine-grained coverage reports. [#67] by [MiWeiss]
66 |
67 | ### Bug Fixes
68 | - Remove a false AssertionError which was raised for long docstrings. [#82] by [MiWeiss]
69 |
70 |
71 |
72 | ## [2.0.1] (2021-03-03)
73 |
74 | ### Bug Fixes
75 | - Fix `--help`/`-h` flag. [#57] by [MiWeiss].
76 |
77 |
78 |
79 | ## [2.0.0] (2021-01-11)
80 |
81 | ### Features
82 | - Add `.docstr.yaml` config file. [#39] by [mBouamama].
83 | - Save docstr-coverage CLI options in `.docstr.yaml` to be used as the default configuration
84 | - For more details, see the [README's "Config File" section](https://github.com/HunterMcGushion/docstr_coverage#config-file)
85 | - Allow `ignore_patterns` to be defined in the `.docstr.yaml` config file. [#46] by [MiWeiss].
86 | - This is an alternative to the `--docstr-ignore-file` CLI option. Do not use both at the same time
87 | - Add `--accept-empty`/`-a` flag to exit with code 0 if no `.py` files are found. [#48] by [MiWeiss].
88 | - Helpful for using `docstr_coverage` in GitHub Actions as described in [#47] by [epassaro]
89 |
90 | ### Deprecations
91 | - Convert all CLI options to kebab-case. [#38] by [cthoyt].
92 |
93 | | New | Deprecated |
94 | |--------------------|------------------|
95 | | `--fail-under` | `--failunder` |
96 | | `--follow-links` | `--followlinks` |
97 | | `--skip-class-def` | `--skipclassdef` |
98 | | `--skip-file-doc` | `--skipfiledoc` |
99 | | `--skip-init` | `--skipinit` |
100 | | `--skip-magic` | `--skipmagic` |
101 |
102 | - :exclamation: **Deprecated option names will be removed in v3.0.0** :exclamation:
103 |
104 | ### Bug Fixes
105 | - Fix Windows compatibility issues and upgrade CI suite. [#45] by [MiWeiss].
106 |
107 |
108 |
109 | ## [1.4.0] (2020-12-05)
110 |
111 | ### Features
112 | * Excuse missing docstrings by marking classes/functions with special comments. [#34] by [MiWeiss].
113 | * `# docstr_coverage: inherited` to mark subclass methods as being documented by their parent
114 | * ```# docstr_coverage: excused `My bad excuse` ``` to arbitrarily excuse missing docstrings
115 | * Find examples and more information [here](https://github.com/HunterMcGushion/docstr_coverage#overriding-by-comments)
116 |
117 |
118 |
119 | ## [1.3.0] (2020-11-17)
120 |
121 | ### Features
122 | * Add ability to skip private functions. [#32] by [cthoyt].
123 |
124 |
125 |
126 | ## [1.2.0] (2020-07-21)
127 |
128 | ### Features
129 | * Added ability to pass multiple paths to CLI for docstring inspection. [#24] by [HunterMcGushion].
130 | * Added `--badge`/`-b` option to generate a docstring coverage percent badge as an SVG image saved
131 | to a given filepath. [#22] by [HunterMcGushion].
132 |
133 | ### Bug Fixes
134 | * Fixed bug where a total coverage of 0 would break everything. [#16] by [killthekitten].
135 | * Fixed bug where the `-d`/`--docstr-ignore-file` CLI option to provide an `ignore_names_file`
136 | would not work with `-v 3`/`--verbose=3`. [#19] by [HunterMcGushion].
137 |
138 | ### Changes
139 | * Added testing safety net. [#16] by [killthekitten].
140 |
141 |
142 |
143 | ## [1.1.0] (2020-05-20)
144 |
145 | ### Features
146 | * Added `--failunder`/`-F` option to fail if coverage is below a given percentage. [#11] by [econchick].
147 | * Default=100.0
148 |
149 |
150 |
151 | ## [1.0.5] (2019-11-18)
152 |
153 | ### Features
154 | * Added `--docstr-ignore-file`/`-d` option to provide a file, containing a list of patterns to
155 | ignore during coverage calculation. [#4] by [Redysz].
156 | * Added support for raising the exit code to be used with CI pipelines. [#6] by [sim0nx].
157 |
158 |
159 |
160 | ## [1.0.4] (2019-04-17)
161 |
162 | ### Features
163 | * Added support for non-ASCII characters. [#3] by [Redysz].
164 |
165 |
166 |
167 | ## [1.0.3] (2019-01-28)
168 |
169 | ### Bug Fixes
170 | * Fixed bug preventing `docstr-coverage` from being properly installed via tox. [#2] by [asergeant01].
171 |
172 |
173 |
174 | ## 0.1.0 (2018-08-01)
175 |
176 | ### Features
177 | * Initial release
178 |
179 |
180 | [Unreleased]: https://github.com/HunterMcGushion/docstr_coverage/compare/v2.3.2...HEAD
181 | [2.3.2]: https://github.com/HunterMcGushion/docstr_coverage/compare/v2.3.1...v2.3.2
182 | [2.3.1]: https://github.com/HunterMcGushion/docstr_coverage/compare/v2.3.0...v2.3.1
183 | [2.3.0]: https://github.com/HunterMcGushion/docstr_coverage/compare/v2.2.0...v2.3.0
184 | [2.2.0]: https://github.com/HunterMcGushion/docstr_coverage/compare/v2.1.1...v2.2.0
185 | [2.1.1]: https://github.com/HunterMcGushion/docstr_coverage/compare/v2.1.0...v2.1.1
186 | [2.1.0]: https://github.com/HunterMcGushion/docstr_coverage/compare/v2.0.1...v2.1.0
187 | [2.0.1]: https://github.com/HunterMcGushion/docstr_coverage/compare/v2.0.0...v2.0.1
188 | [2.0.0]: https://github.com/HunterMcGushion/docstr_coverage/compare/v1.4.0...v2.0.0
189 | [1.4.0]: https://github.com/HunterMcGushion/docstr_coverage/compare/v1.3.0...v1.4.0
190 | [1.3.0]: https://github.com/HunterMcGushion/docstr_coverage/compare/v1.2.0...v1.3.0
191 | [1.2.0]: https://github.com/HunterMcGushion/docstr_coverage/compare/v1.1.0...v1.2.0
192 | [1.1.0]: https://github.com/HunterMcGushion/docstr_coverage/compare/v1.0.5...v1.1.0
193 | [1.0.5]: https://github.com/HunterMcGushion/docstr_coverage/compare/v1.0.4...v1.0.5
194 | [1.0.4]: https://github.com/HunterMcGushion/docstr_coverage/compare/v1.0.3...v1.0.4
195 | [1.0.3]: https://github.com/HunterMcGushion/docstr_coverage/compare/v1.0.2...v1.0.3
196 |
197 |
198 | [asergeant01]: https://github.com/asergeant01
199 | [bjornconstructors]: https://github.com/bjornconstructors
200 | [caffeinatedMike]: https://github.com/caffeinatedMike
201 | [cthoyt]: https://github.com/cthoyt
202 | [econchick]: https://github.com/econchick
203 | [epassaro]: https://github.com/epassaro
204 | [HunterMcGushion]: https://github.com/HunterMcGushion
205 | [killthekitten]: https://github.com/killthekitten
206 | [mBouamama]: https://github.com/mBouamama
207 | [MiWeiss]: https://github.com/MiWeiss
208 | [Redysz]: https://github.com/Redysz
209 | [sim0nx]: https://github.com/sim0nx
210 |
211 |
212 | [#2]: https://github.com/HunterMcGushion/docstr_coverage/pull/2
213 | [#3]: https://github.com/HunterMcGushion/docstr_coverage/pull/3
214 | [#4]: https://github.com/HunterMcGushion/docstr_coverage/pull/4
215 | [#6]: https://github.com/HunterMcGushion/docstr_coverage/pull/6
216 | [#11]: https://github.com/HunterMcGushion/docstr_coverage/pull/11
217 | [#16]: https://github.com/HunterMcGushion/docstr_coverage/pull/16
218 | [#19]: https://github.com/HunterMcGushion/docstr_coverage/pull/19
219 | [#22]: https://github.com/HunterMcGushion/docstr_coverage/pull/22
220 | [#24]: https://github.com/HunterMcGushion/docstr_coverage/pull/24
221 | [#32]: https://github.com/HunterMcGushion/docstr_coverage/pull/32
222 | [#34]: https://github.com/HunterMcGushion/docstr_coverage/pull/34
223 | [#38]: https://github.com/HunterMcGushion/docstr_coverage/pull/38
224 | [#39]: https://github.com/HunterMcGushion/docstr_coverage/pull/39
225 | [#45]: https://github.com/HunterMcGushion/docstr_coverage/pull/45
226 | [#46]: https://github.com/HunterMcGushion/docstr_coverage/pull/46
227 | [#47]: https://github.com/HunterMcGushion/docstr_coverage/issues/47
228 | [#48]: https://github.com/HunterMcGushion/docstr_coverage/pull/48
229 | [#57]: https://github.com/HunterMcGushion/docstr_coverage/pull/57
230 | [#67]: https://github.com/HunterMcGushion/docstr_coverage/pull/67
231 | [#78]: https://github.com/HunterMcGushion/docstr_coverage/pull/78
232 | [#82]: https://github.com/HunterMcGushion/docstr_coverage/pull/82
233 | [#84]: https://github.com/HunterMcGushion/docstr_coverage/pull/84
234 | [#85]: https://github.com/HunterMcGushion/docstr_coverage/pull/85
235 | [#86]: https://github.com/HunterMcGushion/docstr_coverage/pull/86
236 | [#87]: https://github.com/HunterMcGushion/docstr_coverage/pull/87
237 | [#106]: https://github.com/HunterMcGushion/docstr_coverage/pull/106
238 | [#116]: https://github.com/HunterMcGushion/docstr_coverage/pull/116
239 | [#117]: https://github.com/HunterMcGushion/docstr_coverage/pull/117
240 | [#139]: https://github.com/HunterMcGushion/docstr_coverage/pull/139
241 | [#142]: https://github.com/HunterMcGushion/docstr_coverage/pull/142
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | @mweiss_ch (twitter direct message).
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Contributions are welcome.
4 | When contributing to this repository, please first discuss the change you wish to make via issue.
5 | Exceptions to this are fixed typos or small bugfixes, for which obviously no issue is required.
6 | Please make sure that all your additions to the code are covered by unit tests.
7 |
8 | You can also contribute without committing code:
9 | Bug reports, feature requests, documentation are highly beneficial to any open source software project!
10 |
11 | Please note we have a code of conduct, please follow it in all your interactions with the project.
12 |
13 |
14 | ### Tips for contributors
15 |
16 | To install locally, run:
17 |
18 | ```bash
19 | pip install -e .
20 | ```
21 |
22 | You will need to install the development dependencies:
23 |
24 | ```bash
25 | python -m venv venv
26 | source venv/bin/activate
27 | pip install -e ".[test,lint]"
28 | ```
29 |
30 | To run the test suite, simply execute
31 |
32 | ```bash
33 | pytest
34 | ```
35 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Hunter D. McGushion
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.md
2 | include LICENSE.txt
3 |
4 | # Badges
5 | include docstr_coverage/templates/*.svg
6 | include tests/sample_files/badges/*.svg
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | `docstr-coverage` is a simple tool that lets you measure your Python source code's
25 | [docstring](http://www.python.org/dev/peps/pep-0257/#what-is-a-docstring) coverage.
26 | It shows which of your functions, classes, methods, and modules don't have docstrings.
27 | It also provide statistics about overall docstring coverage for individual files, and for your entire project.
28 |
29 | - [Source](https://github.com/HunterMcGushion/docstr_coverage)
30 | - [Documentation](https://docstr-coverage.readthedocs.io/en/latest/api_essentials.html)
31 |
32 | ## Example
33 |
34 | ```bash
35 | >>> HunterMcGushion$ docstr-coverage /docstr_coverage/
36 |
37 | File: "docstr_coverage/setup.py"
38 | - No module docstring
39 | - No docstring for `readme`
40 | Needed: 2; Found: 0; Missing: 2; Coverage: 0.0%
41 |
42 | File: "docstr_coverage/docstr_coverage/__init__.py"
43 | - No module docstring
44 | Needed: 1; Found: 0; Missing: 1; Coverage: 0.0%
45 |
46 | File: "docstr_coverage/docstr_coverage/coverage.py"
47 | - No docstring for `DocStringCoverageVisitor.__init__`
48 | Needed: 11; Found: 10; Missing: 1; Coverage: 90.9%
49 |
50 |
51 | Overall statistics for 3 files:
52 | Docstrings needed: 14; Docstrings found: 10; Docstrings missing: 4
53 | Total docstring coverage: 71.4%; Grade: Very good
54 | ```
55 |
56 | ## How Do I Use It
57 |
58 | ### Command-line Tool
59 |
60 | General usage is: `docstr-coverage [options]`
61 |
62 | To test a single module, named `some_module.py`, run:
63 |
64 | ```bash
65 | docstr-coverage some_module.py
66 | ```
67 |
68 | To test a directory (recursively), just supply the directory `some_project/src` instead:
69 |
70 | ```bash
71 | docstr-coverage some_project/src
72 | ```
73 |
74 | #### Options
75 |
76 | - _--destination=\, -dst \_ - Set the results output destination (default stdout)
77 | - stdout - Output to standard STDOUT.
78 | - file - Save output to file.
79 | - _--format=\, -frm \_ - Set output style (default text)
80 | - text - Output in simple style.
81 | - markdown - Output in Markdown notation.
82 | - _--skip-magic, -m_ - Ignore all magic methods (except `__init__`)
83 | - _--skip-init, -i_ - Ignore all `__init__` methods
84 | - _--skip-file-doc, -f_ - Ignore module docstrings (at the top of files)
85 | - _--skip-private, -P_ - Ignore private functions (starting with a single underscore)
86 | - _--skip-class-def, -c_ - Ignore docstrings of class definitions
87 | - _--skip-property, -sp_ - Ignore functions with `@property` decorator
88 | - _--include-setter, -is_ - Include functions with `@setter` decorator (skipped by default)
89 | - _--include-deleter, -idel_ - Include functions with `@deleter` decorator (skipped by default)
90 | - _--accept-empty, -a_ - Exit with code 0 if no Python files are found (default: exit code 1)
91 | - _--exclude=\, -e \_ - Filepath pattern to exclude from analysis
92 | - To exclude the contents of a virtual environment `env` and your `tests` directory, run:
93 | ```docstr-coverage some_project/ -e ".*/(env|tests)"```
94 | - _--verbose=\, -v \_ - Set verbosity level (0-3, default: 3)
95 | - 0 - Silence
96 | - 1 - Print overall statistics
97 | - 2 - Also print individual statistics for each file
98 | - 3 - Also print missing docstrings (function names, class names, etc.)
99 | - 4 - Also print information about present docstrings
100 | - _--fail-under=, -F _ - Fail if under a certain percentage of coverage (default: 100.0)
101 | - _--badge=\, -b \_ - Generate a docstring coverage percent badge as an SVG saved to a given filepath
102 | - Include the badge in a repo's README using
103 | ```[![docstr_coverage]()](https://github.com/HunterMcGushion/docstr_coverage)```,
104 | where `` is the path provided to the `--badge` option
105 | - _--follow-links, -l_ - Follow symlinks
106 | - _--percentage-only, -p_ - Output only the overall coverage percentage as a float, silencing all other logging
107 | - _--help, -h_ - Display CLI options
108 |
109 | #### Config File
110 | All options can be saved in a config file. A file named `.docstr.yaml` in the folder in which `docstr-coverage` is executed is picked up automatically.
111 | Other locations can be passed using `docstr-coverage -C path/to/config.yml` or the long version `--config`.
112 |
113 | Example:
114 | ```yaml
115 | paths: # list or string
116 | - docstr_coverage
117 | badge: docs # Path
118 | exclude: .*/test # regex
119 | verbose: 3 # int (0-4)
120 | skip_magic: True # Boolean
121 | skip_file_doc: True # Boolean
122 | skip_init: True # Boolean
123 | skip_class_def: True # Boolean
124 | skip_private: True # Boolean
125 | follow_links: True # Boolean
126 | accept_empty: True # Boolean
127 | ignore_names_file: .*/test # regex
128 | fail_under: 90 # int
129 | percentage_only: True # Boolean
130 | ignore_patterns: # Dict with key/value pairs of file-pattern/node-pattern
131 | .*: method_to_ignore_in_all_files
132 | FileWhereWeWantToIgnoreAllSpecialMethods: "__.+__"
133 | SomeFile:
134 | - method_to_ignore1
135 | - method_to_ignore2
136 | - method_to_ignore3
137 | a_very_important_view_file:
138 | - "^get$"
139 | - "^set$"
140 | - "^post$"
141 | detect_.*:
142 | - "get_val.*"
143 | ```
144 | equivalent to
145 | ```
146 | docstr-coverage docstr_coverage -e ".*/test" --skip-magic --skip-init --badge="docs" --skip-class-def etc...
147 | ```
148 |
149 | Note that options passed as command line arguments have precedence over options
150 | configured in a config file.
151 |
152 | #### Ignoring by Regex
153 | In your config files, using `ignore_patterns`, you can specify regex patterns for files names and nodes (methods, ...)
154 | which should be ignored. See config file example above.
155 |
156 | #### Overriding by Comments
157 | Note that `docstr-coverage` can not parse
158 | dynamically added documentation (e.g. through class extension).
159 | Thus, some of your code which deliberately has no docstring might be counted as uncovered.
160 |
161 | You can override this by adding either ```# docstr-coverage:inherited```
162 | (intended for use if a docstring is provided in the corresponding superclass method)
163 | or a generic excuse with a reason, like ```# docstr-coverage:excused `My probably bad excuse` ```.
164 | These have to be stated right above any class or function definition
165 | (or above the functions annotations, if applicable).
166 | Such class or function would then be counted as if they had a docstring.
167 |
168 | ```python
169 | # docstr-coverage:excused `no one is reading this anyways`
170 | class FooBarChild(FooBar):
171 |
172 | # docstr-coverage:inherited
173 | def function(self):
174 | pass
175 | ```
176 |
177 | #### Pre-commit hook
178 |
179 | You can use `docstr-coverage` as a pre-commit hook by adding the following to your `.pre-commit-config.yaml` file
180 | and configuring the `paths` section of the [`.docstr.yaml` config](#config-file).
181 | This is preferrable over [pre-commit args](https://pre-commit.com/#config-args),
182 | as it facilitates the use of the same config in CI, pre-commit and manual runs.
183 |
184 | ```yaml
185 | repos:
186 | - repo: https://github.com/HunterMcGushion/docstr_coverage
187 | rev: v2.3.2 # most recent docstr-coverage release or commit sha
188 | hooks:
189 | - id: docstr-coverage
190 | args: ["--verbose", "2"] # override the .docstr.yaml to see less output
191 | ```
192 |
193 | #### Package in Your Project
194 |
195 | You can also use `docstr-coverage` as a part of your project by importing it thusly.
196 | It will supply you with overall and per-file coverages:
197 |
198 | ```python
199 | from docstr_coverage import get_docstring_coverage
200 | my_coverage = get_docstring_coverage(['some_dir/file_0.py', 'some_dir/file_1.py'])
201 | ```
202 |
203 | If you want more fine grained information, try the experimental `docstr_coverage.analyze()`
204 | ```python
205 | from docstr_coverage import analyze
206 | coverage_report = analyze(['some_dir/file_0.py', 'some_dir/file_1.py'])
207 | coverage = coverage_report.count_aggregate().coverage()
208 | ```
209 |
210 | ## Why Should I Use It
211 |
212 | - Thorough documentation is important to help others (and even yourself) understand your code
213 | - As a developer, improve your code's maintainability for when you need to make updates and fix bugs
214 | - As a user, instantly know how easy it's going to be to understand a new library \* If its documentation coverage is low, you may need to figure a lot out for yourself
215 |
216 | ## Installation
217 |
218 | ```bash
219 | pip install docstr-coverage
220 | ```
221 |
222 | If you like being on the cutting-edge, and you want all the latest developments, run:
223 |
224 | ```bash
225 | pip install git+https://github.com/HunterMcGushion/docstr_coverage.git
226 | ```
227 |
228 | ## Special Thanks
229 |
230 | Thank you to Alexey "DataGreed" Strelkov, and James Harlow for doing all the hard work.
231 | `docstr-coverage` simply revives and brings their efforts to Python 3. See 'THANKS.txt' for more information.
232 |
--------------------------------------------------------------------------------
/THANKS.txt:
--------------------------------------------------------------------------------
1 | - Alexey "DataGreed" Strelkov
2 | - For creating the original `docstring-coverage` library (https://bitbucket.org/DataGreed/docstring-coverage)
3 | - Support seems to have ended on 2012-08-14. Hopefully I'm not stepping on any toes by picking up where you left off
4 | - Thank you for your excellent work, and just as you say you owe James Harlow a drink, I owe you one, as well
5 | - James Harlow
6 | - For authoring the 2004 "Docstring Coverage Python Recipe" (http://code.activestate.com/recipes/355731/)
7 | - Credited by Alexey Strelkov as being the basis of `docstring-coverage`
8 | - Thank you. Obviously, I also owe you a drink
9 | - Also on ActiveState's Recipes Repository (github.com/ActiveState/code/tree/master/recipes/Python/355731_Docstring_coverage)
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SPHINXPROJ = docstr_coverage
8 | SOURCEDIR = .
9 | BUILDDIR = _build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
--------------------------------------------------------------------------------
/docs/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-cayman
--------------------------------------------------------------------------------
/docs/api_essentials.rst:
--------------------------------------------------------------------------------
1 | Docstr-Coverage API Essentials
2 | ***********************************
3 | This section exposes the API for all the docstr-coverage functionality that will be necessary for most users.
4 |
5 | get_docstring_coverage
6 | ======================
7 |
8 | .. autofunction:: docstr_coverage.coverage.get_docstring_coverage
9 | :noindex:
10 |
11 |
12 | Indices and tables
13 | ==================
14 |
15 | * :ref:`genindex`
16 | * :ref:`modindex`
17 | * :ref:`search`
--------------------------------------------------------------------------------
/docs/complete_api.rst:
--------------------------------------------------------------------------------
1 | Complete Docstr-Coverage API
2 | *********************************
3 | This section exposes the complete docstr-coverage API.
4 |
5 | * :ref:`genindex`
6 | * :ref:`modindex`
7 | * :ref:`search`
8 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Configuration file for the Sphinx documentation builder. This file does only contain a selection of the most common options.
3 | # For a full list see the documentation: http://www.sphinx-doc.org/en/master/config
4 |
5 | ##################################################
6 | # Path Setup
7 | ##################################################
8 | # If extensions (or modules to document with autodoc) are in another directory, add these directories to sys.path here. If the
9 | # directory is relative to the documentation root, use os.path.abspath to make it absolute, like shown here.
10 | import os
11 | import sys
12 |
13 | sys.path.insert(0, os.path.abspath("."))
14 | sys.path.insert(0, os.path.abspath(".."))
15 |
16 | ##################################################
17 | # Project Information
18 | ##################################################
19 | project = "docstr_coverage"
20 | copyright = "2018, Hunter McGushion"
21 | author = "Hunter McGushion"
22 |
23 | version = "" # The short X.Y version
24 | release = "2.3.2" # The full version, including alpha/beta/rc tags
25 |
26 | ##################################################
27 | # General Configuration
28 | ##################################################
29 | # If your documentation needs a minimal Sphinx version, state it here.
30 | # needs_sphinx = '1.0'
31 |
32 | # Add any Sphinx extension module names here, as strings. They can be
33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
34 | extensions = [
35 | "sphinx.ext.autodoc",
36 | "sphinx.ext.coverage",
37 | "sphinx.ext.viewcode",
38 | "sphinx.ext.githubpages",
39 | "sphinx.ext.napoleon",
40 | ]
41 |
42 | # Add any paths that contain templates here, relative to this directory.
43 | templates_path = ["_templates"]
44 |
45 | # The suffix(es) of source filenames. You can specify multiple suffix as a list of string: `source_suffix = ['.rst', '.md']`
46 | source_suffix = ".rst"
47 |
48 | # The master toctree document.
49 | master_doc = "index"
50 |
51 | # The language for content autogenerated by Sphinx. Refer to documentation for a list of supported languages. This is also used
52 | # if you do content translation via gettext catalogs. Usually you set "language" from the command line for these cases.
53 | language = None
54 |
55 | # List of patterns, relative to source directory, that match files and directories to ignore when looking for source files.
56 | # This pattern also affects html_static_path and html_extra_path.
57 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
58 |
59 | # The name of the Pygments (syntax highlighting) style to use.
60 | pygments_style = "sphinx"
61 |
62 | ##################################################
63 | # AutoDocumentation/Napoleon Settings
64 | ##################################################
65 | napoleon_google_docstring = False
66 |
67 |
68 | ##################################################
69 | # Options for HTML Output
70 | ##################################################
71 | # The theme to use for HTML and HTML Help pages. See the documentation for a list of builtin themes.
72 | html_theme = "sphinx_rtd_theme" # 'nature'
73 |
74 | # Theme options are theme-specific and customize the look and feel of a theme further. For a list of options available for each
75 | # theme, see the documentation.
76 | # html_theme_options = {}
77 |
78 | # Add any paths that contain custom static files (such as style sheets) here, relative to this directory. They are copied after
79 | # the builtin static files, so a file named "default.css" will overwrite the builtin "default.css".
80 | html_static_path = ["_static"]
81 | modindex_common_prefix = ["docstr_coverage."]
82 |
83 | # Custom sidebar templates, must be a dictionary that maps document names to template names. The default sidebars (for documents
84 | # that don't match any pattern) are defined by theme itself. Builtin themes are using these templates by default:
85 | # ``['localtoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html']``.
86 | # html_sidebars = {}
87 |
88 | ##################################################
89 | # Options for HTMLHelp Output
90 | ##################################################
91 | # Output file base name for HTML help builder.
92 | htmlhelp_basename = "docstr_coveragedoc"
93 |
94 | ##################################################
95 | # Options for LaTeX Output
96 | ##################################################
97 | latex_elements = {
98 | # 'papersize': 'letterpaper', # The paper size ('letterpaper' or 'a4paper')
99 | # 'pointsize': '10pt', # The font size ('10pt', '11pt' or '12pt')
100 | # 'preamble': '', # Additional stuff for the LaTeX preamble
101 | # 'figure_align': 'htbp', # Latex figure (float) alignment
102 | }
103 |
104 | # Grouping the document tree into LaTeX files. List of tuples:
105 | # (source start file, target name, title, author, documentclass [howto, manual, or own class]).
106 | latex_documents = [
107 | (
108 | master_doc,
109 | "docstr_coverage.tex",
110 | "docstr\\_coverage Documentation",
111 | "Hunter McGushion",
112 | "manual",
113 | )
114 | ]
115 |
116 | ##################################################
117 | # Options for Manual Page Output
118 | ##################################################
119 | # One entry per manual page. List of tuples: (source start file, name, description, authors, manual section).
120 | man_pages = [(master_doc, "docstr_coverage", "docstr_coverage Documentation", [author], 1)]
121 |
122 | ##################################################
123 | # Options for Texinfo Output
124 | ##################################################
125 | # Grouping the document tree into Texinfo files. List of tuples:
126 | # (source start file, target name, title, author, dir menu entry, description, category)
127 | texinfo_documents = [
128 | (
129 | master_doc,
130 | "docstr_coverage",
131 | "docstr_coverage Documentation",
132 | author,
133 | "docstr_coverage",
134 | "One line description of project.",
135 | "Miscellaneous",
136 | )
137 | ]
138 |
139 | ##################################################
140 | # Extension Configuration
141 | ##################################################
142 | # def docstring_processor(app, what, name, obj, options, lines):
143 | # # See http://www.sphinx-doc.org/en/master/ext/autodoc.html#event-autodoc-process-docstring
144 | # pass # Expected to modify `lines` in-place
145 | #
146 | # def signature_processor(app, what, name, obj, options, signature, return_annotation):
147 | # # See http://www.sphinx-doc.org/en/master/ext/autodoc.html#event-autodoc-process-signature
148 | # pass # Expected to return tuple (`signature`, `return_annotation`) to change Sphinx output
149 | #
150 | # def setup(app):
151 | # # app.connect('autodoc-process-docstring', docstring_processor)
152 | # # app.connect('autodoc-process-signature', signature_processor)
153 | # pass
154 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | Welcome to docstr_coverage's Documentation
2 | ******************************************
3 |
4 | .. toctree::
5 | :maxdepth: 2
6 |
7 | API Essentials
8 | Complete API
9 |
10 |
11 |
12 | Indices and tables
13 | ==================
14 |
15 | * :ref:`genindex`
16 | * :ref:`modindex`
17 | * :ref:`search`
18 |
--------------------------------------------------------------------------------
/docs/logo_wide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HunterMcGushion/docstr_coverage/9bd0ea86ca310ee7125f6050ee3ba3ea04fa8801/docs/logo_wide.png
--------------------------------------------------------------------------------
/docs/source/docstr_coverage.rst:
--------------------------------------------------------------------------------
1 | docstr\_coverage package
2 | ========================
3 |
4 | Submodules
5 | ----------
6 |
7 | docstr\_coverage.coverage module
8 | --------------------------------
9 |
10 | .. automodule:: docstr_coverage.coverage
11 | :members:
12 | :undoc-members:
13 | :show-inheritance:
14 |
15 |
16 | Module contents
17 | ---------------
18 |
19 | .. automodule:: docstr_coverage
20 | :members:
21 | :undoc-members:
22 | :show-inheritance:
23 |
--------------------------------------------------------------------------------
/docs/source/modules.rst:
--------------------------------------------------------------------------------
1 | docstr_coverage
2 | ===============
3 |
4 | .. toctree::
5 | :maxdepth: 4
6 |
7 | docstr_coverage
8 |
--------------------------------------------------------------------------------
/docstr_coverage/__init__.py:
--------------------------------------------------------------------------------
1 | ##################################################
2 | # Set __all__
3 | ##################################################
4 | from .coverage import analyze, get_docstring_coverage
5 | from .ignore_config import IgnoreConfig
6 | from .printers import LegacyPrinter
7 | from .result_collection import (
8 | AggregatedCount,
9 | ExpectedDocstring,
10 | FileCount,
11 | ResultCollection,
12 | )
13 |
14 | __all__ = [
15 | "analyze",
16 | "get_docstring_coverage",
17 | "IgnoreConfig",
18 | "LegacyPrinter",
19 | "ResultCollection",
20 | "AggregatedCount",
21 | "ExpectedDocstring",
22 | "FileCount",
23 | ]
24 |
--------------------------------------------------------------------------------
/docstr_coverage/badge.py:
--------------------------------------------------------------------------------
1 | """Module for creating docstring coverage badges.
2 |
3 | Notes
4 | -----
5 | This module is based on the excellent [coverage-badge](https://github.com/dbrgn/coverage-badge)
6 | repository. Thank you to the authors for their fantastic work! Go give
7 | [coverage-badge](https://github.com/dbrgn/coverage-badge) a star!"""
8 |
9 | import os
10 | import sys
11 |
12 | if sys.version_info >= (3, 9):
13 | from importlib.resources import files
14 | else:
15 | from importlib_resources import files
16 |
17 | COLORS = {
18 | "brightgreen": "#4c1",
19 | "green": "#97CA00",
20 | "yellowgreen": "#a4a61d",
21 | "yellow": "#dfb317",
22 | "orange": "#fe7d37",
23 | "red": "#e05d44",
24 | "lightgrey": "#9f9f9f",
25 | }
26 |
27 | COLOR_RANGES = [
28 | (95, "brightgreen"),
29 | (90, "green"),
30 | (75, "yellowgreen"),
31 | (60, "yellow"),
32 | (40, "orange"),
33 | (0, "red"),
34 | ]
35 |
36 |
37 | class Badge:
38 | def __init__(self, path: str, coverage: float):
39 | """Class to build and save a coverage badge based on `coverage` results
40 |
41 | Parameters
42 | ----------
43 | path: String
44 | File or directory path to which the coverage badge SVG should be saved. If `path` is a
45 | directory, the badge will be saved under the filename "docstr_coverage_badge.svg"
46 | coverage: Float
47 | Docstring coverage percentage"""
48 | self.path = path
49 | self.coverage = round(coverage)
50 |
51 | self._color = None
52 | self._badge = None
53 |
54 | #################### Properties ####################
55 | @property
56 | def path(self) -> str:
57 | """String: Filepath to which the coverage badge SVG should be saved. If set to a directory,
58 | "docstr_coverage_badge.svg" will be appended"""
59 | return self._path
60 |
61 | @path.setter
62 | def path(self, value: str):
63 | if os.path.isdir(value):
64 | value = os.path.join(value, "docstr_coverage_badge.svg")
65 | if not value.endswith(".svg"):
66 | value += ".svg"
67 | self._path = value
68 |
69 | @property
70 | def color(self) -> str:
71 | """String: Hex color code to use for badge based on :attr:`coverage`"""
72 | if self._color is None:
73 | for (minimum, color) in COLOR_RANGES:
74 | if self.coverage >= minimum:
75 | self._color = COLORS[color]
76 | break
77 | else:
78 | self._color = COLORS["lightgrey"]
79 | return self._color
80 |
81 | @property
82 | def badge(self) -> str:
83 | """String: SVG badge contents"""
84 | if self._badge is None:
85 | value = "{:.0f}".format(self.coverage)
86 | template_path = os.path.join("templates", "flat.svg")
87 | template = files(__package__).joinpath(template_path).read_text(encoding="utf-8")
88 | self._badge = template.replace("{{ value }}", value).replace("{{ color }}", self.color)
89 | return self._badge
90 |
91 | #################### Core Methods ####################
92 | def save(self) -> str:
93 | """Save :attr:`badge` to :attr:`path`
94 |
95 | Returns
96 | -------
97 | path: String
98 | Full filepath to which the badge SVG is saved"""
99 | with open(self.path, "w") as f:
100 | f.write(self.badge)
101 | return self.path
102 |
--------------------------------------------------------------------------------
/docstr_coverage/cli.py:
--------------------------------------------------------------------------------
1 | """This module is the CLI entry point for `docstr_coverage` in which CLI arguments are defined and
2 | passed on to other modules"""
3 | import os
4 | import platform
5 | import re
6 | import sys
7 | import warnings
8 | from typing import List, Optional
9 |
10 | import click
11 |
12 | from docstr_coverage.badge import Badge
13 | from docstr_coverage.config_file import set_config_defaults
14 | from docstr_coverage.coverage import analyze
15 | from docstr_coverage.ignore_config import IgnoreConfig
16 | from docstr_coverage.printers import LegacyPrinter, MarkdownPrinter
17 |
18 |
19 | def do_include_filepath(filepath: str, exclude_re: Optional["re.Pattern"]) -> bool:
20 | """Determine whether `filepath` should be included in docstring search.
21 | Note on regex matching:
22 | On windows we check against unix and windows regex matches (if one of the two matches)
23 | Hence we have a one-way compatibility (regex for unix paths work in win as well).
24 | Two way compatibility (matching win-path regexes on linux) is not possible as a single backslash
25 | is a valid character in a unix path.
26 |
27 | Parameters
28 | ----------
29 | filepath: String
30 | Filepath to match with `exclude_re`. If extension is not ".py", it will be excluded
31 | exclude_re: re.Pattern, or None
32 | Pattern for files to be excluded. If None, `exclude_re` is ignored
33 |
34 | Returns
35 | -------
36 | Boolean
37 | True if `filepath` should be searched, else False"""
38 | if not filepath.endswith(".py"):
39 | return False
40 | if exclude_re is not None:
41 | if exclude_re.match(filepath):
42 | return False
43 | if platform.system() == "Windows":
44 | return not exclude_re.match(filepath.replace("\\", "/"))
45 | return True
46 |
47 |
48 | def collect_filepaths(
49 | *paths: str, follow_links: bool = False, exclude: Optional[str] = None
50 | ) -> List[str]:
51 | """Collect filepaths under given `paths` that are not `exclude`-d
52 |
53 | Parameters
54 | ----------
55 | *paths: String
56 | Path(s) to a directory/file from which filepaths will be collected
57 | follow_links: Boolean, default=False
58 | Whether to follow symbolic links when traversing directories in `paths`
59 | exclude: String (optional)
60 | If not None, used as a regex Pattern to exclude filepaths during collection. If a full
61 | filepath matches the `exclude` pattern, it is skipped
62 |
63 | Returns
64 | -------
65 | List
66 | List of string filepaths found under `paths` that are not excluded. If `paths` is a single
67 | ".py" file, result will be [`paths`]. Otherwise, the contents of `paths` that are not
68 | `exclude`-d will comprise the result"""
69 | exclude_re = re.compile(r"{}".format(exclude)) if exclude else None
70 | filepaths = []
71 |
72 | for path in paths:
73 | if path.endswith(".py"):
74 | filepaths.append(path)
75 | else:
76 | for (dirpath, dirnames, filenames) in os.walk(path, followlinks=follow_links):
77 | candidates = [os.path.join(dirpath, _) for _ in filenames]
78 | filepaths.extend([_ for _ in candidates if do_include_filepath(_, exclude_re)])
79 |
80 | return sorted(filepaths)
81 |
82 |
83 | def parse_ignore_names_file(ignore_names_file: str) -> tuple:
84 | """Parse a file containing patterns of names to ignore
85 |
86 | Parameters
87 | ----------
88 | ignore_names_file: String
89 | Path to a file containing name patterns
90 |
91 | Returns
92 | -------
93 | Tuple
94 | Tuple containing one list for each valid line in `ignore_names_file`. Each list contains
95 | the space-delimited contents of that line in the file, in which the first value is a file
96 | pattern, and all other values are name patterns"""
97 | if not os.path.isfile(ignore_names_file):
98 | return ()
99 |
100 | with open(ignore_names_file, "r") as f:
101 | ignore_names = tuple([line.split() for line in f.readlines() if " " in line])
102 |
103 | return ignore_names
104 |
105 |
106 | def parse_ignore_patterns_from_dict(ignore_patterns_dict) -> tuple:
107 | """Parse dictionary containing (file_name_pattern, exclude_patterns) key value pairs
108 | to return an output consistent with ignore patterns parsed by `parse_ignore_names_file`
109 |
110 | Parameters
111 | ----------
112 | ignore_patterns_dict: Dict
113 | A dict where each key is a string and each value is a string or a nonempty list of strings.
114 |
115 | Returns
116 | -------
117 | Tuple
118 | Tuple of iterables of string with the same form as the output of `parse_ignore_names_file`
119 |
120 | Notes
121 | -----
122 | To align the workflow with `parse_ignore_names_file`, we check that the passed values
123 | are of type string, but we do not yet check if they are valid regular expressions"""
124 |
125 | def _assert_valid_key_value(k, v):
126 | if not isinstance(k, str):
127 | raise TypeError("ignore patterns in config contained non-string key {}".format(k))
128 | if len(k.strip()) == 0:
129 | raise ValueError("ignore pattern in config contained empty (file name) regex")
130 | if not all(isinstance(v, str) for v in v) and len(v) > 0:
131 | raise TypeError(
132 | "ignore patterns for key {} contained non-string values or was empty.".format(k)
133 | )
134 | if not all(len(v.strip()) > 0 for v in v):
135 | raise ValueError("ignore pattern for key {} contained empty regex".format(k))
136 |
137 | if not isinstance(ignore_patterns_dict, dict):
138 | raise TypeError(
139 | "ignore patterns in config must have type Dict[str, Union[str, List[str]]],"
140 | "but was {}".format(type(ignore_patterns_dict))
141 | )
142 |
143 | result_list = []
144 | for key, value in ignore_patterns_dict.items():
145 | res = [key]
146 | if not isinstance(value, list):
147 | value = [value]
148 | _assert_valid_key_value(key, value)
149 | res += value
150 | result_list.append(res)
151 |
152 | return tuple(result_list)
153 |
154 |
155 | @click.command()
156 | @click.option(
157 | "-v",
158 | "--verbose",
159 | type=click.Choice(["0", "1", "2", "3", "4"]),
160 | default="3",
161 | help="Verbosity level",
162 | show_default=True,
163 | callback=lambda _ctx, _param, value: int(value),
164 | )
165 | @click.option(
166 | "-e",
167 | "--exclude",
168 | type=str,
169 | default=None,
170 | help="Regex identifying filepaths to exclude",
171 | show_default=False,
172 | # TODO: Add support for multiple `--exclude` regex invocations
173 | )
174 | @click.option(
175 | "-m",
176 | "--skip-magic",
177 | is_flag=True,
178 | help="Ignore docstrings of magic methods (except `__init__`)",
179 | )
180 | @click.option("-f", "--skip-file-doc", is_flag=True, help="Ignore module docstrings")
181 | @click.option("-i", "--skip-init", is_flag=True, help="Ignore docstrings of `__init__` methods")
182 | @click.option("-c", "--skip-class-def", is_flag=True, help="Ignore docstrings of class definitions")
183 | @click.option(
184 | "-sp", "--skip-property", is_flag=True, help="Ignore functions with @property decorator"
185 | )
186 | @click.option(
187 | "-is",
188 | "--include-setter",
189 | is_flag=True,
190 | help="Include functions with @setter decorator (default: ignored)",
191 | )
192 | @click.option(
193 | "-idel",
194 | "--include-deleter",
195 | is_flag=True,
196 | help="Include functions with @deleter decorator (default: ignored)",
197 | )
198 | @click.option(
199 | "-P",
200 | "--skip-private",
201 | is_flag=True,
202 | help="Ignore docstrings of functions starting with a single underscore",
203 | )
204 | @click.option("-l", "--follow-links", is_flag=True, help="Follow symlinks")
205 | @click.option(
206 | "-F",
207 | "--fail-under",
208 | type=float,
209 | default=100.0,
210 | help="Fail when coverage % is less than a given amount",
211 | show_default=True,
212 | metavar="NUMBER",
213 | )
214 | @click.option(
215 | "-b",
216 | "--badge",
217 | type=click.Path(exists=False, resolve_path=True),
218 | default=None,
219 | help="Generate a docstring coverage percent badge as an SVG saved to a given filepath",
220 | show_default=False,
221 | )
222 | @click.option(
223 | "-p",
224 | "--percentage-only",
225 | is_flag=True,
226 | help="Output only the overall coverage percentage as a float, silencing all other logging",
227 | )
228 | @click.option(
229 | "-a",
230 | "--accept-empty",
231 | is_flag=True,
232 | help="Exit with code 0 if no Python files are found (default: exit code 1)",
233 | )
234 | @click.help_option("-h", "--help")
235 | @click.argument(
236 | "paths",
237 | type=click.Path(exists=True, file_okay=True, dir_okay=True, readable=True, resolve_path=True),
238 | nargs=-1,
239 | is_eager=True, # Eagerly execute before `config` so `set_config_defaults` has `paths`
240 | )
241 | @click.option(
242 | "-C",
243 | "--config",
244 | type=click.Path(exists=False, resolve_path=True),
245 | default=".docstr.yaml",
246 | help="Configuration file containing option defaults",
247 | is_eager=True,
248 | callback=set_config_defaults,
249 | )
250 | @click.option("--skipmagic", is_flag=True, help="Deprecated. Use --skip-magic")
251 | @click.option("--skipfiledoc", is_flag=True, help="Deprecated. Use --skip-file-doc")
252 | @click.option("--skipinit", is_flag=True, help="Deprecated. Use --skip-init")
253 | @click.option("--skipclassdef", is_flag=True, help="Deprecated. Use --skip-class-def")
254 | @click.option("--followlinks", is_flag=True, help="Deprecated. Use --follow-links")
255 | @click.option("--failunder", type=float, help="Deprecated. Use --fail-under")
256 | @click.option(
257 | "-d",
258 | "--docstr-ignore-file",
259 | "ignore_names_file",
260 | type=click.Path(exists=False, resolve_path=True),
261 | default=".docstr_coverage",
262 | help="Deprecated. Use json config (--config / -C) instead",
263 | )
264 | @click.option(
265 | "-dst",
266 | "--destination",
267 | type=click.Choice(["stdout", "file"]),
268 | default="stdout",
269 | help="Results output destination",
270 | show_default=True,
271 | metavar="DESTINATION",
272 | )
273 | @click.option(
274 | "-frm",
275 | "--format",
276 | type=click.Choice(["text", "markdown"]),
277 | default="text",
278 | help="Format of output",
279 | show_default=True,
280 | metavar="FORMAT",
281 | )
282 | def execute(paths, **kwargs):
283 | """Measure docstring coverage for `PATHS`"""
284 |
285 | _deprecation_alerts(kwargs)
286 |
287 | # handle fail under
288 | if kwargs.get("failunder") is not None:
289 | if kwargs.get("fail_under") != 100.0:
290 | raise ValueError("Should not set deprecated --failunder and --fail-under")
291 | click.secho("Using deprecated --failunder, should use --fail-under", fg="red")
292 | kwargs["fail_under"] = kwargs.pop("failunder")
293 |
294 | # TODO: Add option to generate pretty coverage reports - Like Python's test `coverage`
295 | # TODO: Add option to sort reports by filename, coverage score... (ascending/descending)
296 | if kwargs["percentage_only"] is True:
297 | # Override verbosity to ensure only the overall percentage is printed
298 | kwargs["verbose"] = 0
299 |
300 | all_paths = collect_filepaths(
301 | *paths, follow_links=kwargs["follow_links"], exclude=kwargs["exclude"]
302 | )
303 |
304 | if len(all_paths) < 1:
305 | if kwargs["accept_empty"] is True:
306 | warnings.warn("No Python files found in specified paths. Processing aborted")
307 | sys.exit(0)
308 | else:
309 | sys.exit(
310 | "No Python files found. "
311 | "Use `--accept-empty` to exit with code 0 if you expect this case, "
312 | "or specify the paths you'd like to check "
313 | "via command line arguments or the config file."
314 | )
315 |
316 | # Parse ignore names file
317 | has_ignore_patterns_in_config = "ignore_patterns" in kwargs
318 | if os.path.isfile(kwargs["ignore_names_file"]) and has_ignore_patterns_in_config:
319 | raise ValueError(
320 | (
321 | "The docstr-coverage configuration file {} contains ignore_patterns,"
322 | " and at the same time a (deprecated) ignore file {} was found."
323 | " Ignore patterns must be specified in only one location at a time."
324 | ).format(kwargs["config_file"], kwargs["ignore_names_file"])
325 | )
326 | elif os.path.isfile(kwargs["ignore_names_file"]):
327 | ignore_names = parse_ignore_names_file(kwargs["ignore_names_file"])
328 | elif has_ignore_patterns_in_config:
329 | ignore_names = parse_ignore_patterns_from_dict(kwargs["ignore_patterns"])
330 | else:
331 | ignore_names = []
332 |
333 | ignore_config = IgnoreConfig(
334 | skip_magic=kwargs["skip_magic"],
335 | skip_file_docstring=kwargs["skip_file_doc"],
336 | skip_init=kwargs["skip_init"],
337 | skip_class_def=kwargs["skip_class_def"],
338 | skip_private=kwargs["skip_private"],
339 | skip_property=kwargs["skip_property"],
340 | skip_setter=not kwargs["include_setter"],
341 | skip_deleter=not kwargs["include_deleter"],
342 | ignore_names=ignore_names,
343 | )
344 |
345 | # Calculate docstring coverage
346 | show_progress = not kwargs["percentage_only"]
347 | results = analyze(all_paths, ignore_config=ignore_config, show_progress=show_progress)
348 |
349 | report_format: str = kwargs["format"]
350 | if report_format == "markdown":
351 | printer = MarkdownPrinter(results, verbosity=kwargs["verbose"], ignore_config=ignore_config)
352 | elif report_format == "text":
353 | printer = LegacyPrinter(results, verbosity=kwargs["verbose"], ignore_config=ignore_config)
354 | else:
355 | raise SystemError("Unknown report format: {0}".format(report_format))
356 |
357 | destination: str = kwargs["destination"]
358 | if destination == "file":
359 | printer.save_to_file()
360 | elif destination == "stdout":
361 | printer.print_to_stdout()
362 | else:
363 | raise SystemError("Unknown output type: {0}".format(destination))
364 |
365 | file_results, total_results = results.to_legacy()
366 |
367 | # Save badge
368 | if kwargs["badge"]:
369 | badge = Badge(kwargs["badge"], total_results["coverage"])
370 | badge.save()
371 |
372 | if kwargs["verbose"]:
373 | print("Docstring coverage badge saved to {!r}".format(badge.path))
374 |
375 | if kwargs["percentage_only"] is True:
376 | print(total_results["coverage"])
377 |
378 | # Exit
379 | if total_results["coverage"] < kwargs["fail_under"]:
380 | raise SystemExit(1)
381 |
382 | raise SystemExit(0)
383 |
384 |
385 | def _deprecation_alerts(kwargs):
386 | """Warns users if they are using deprecated flags"""
387 | for deprecated_name, name in [
388 | ("skipmagic", "skip_magic"),
389 | ("skipfiledoc", "skip_file_doc"),
390 | ("skipinit", "skip_init"),
391 | ("skipclassdef", "skip_class_def"),
392 | ("followlinks", "follow_links"),
393 | ]:
394 | if kwargs.get(deprecated_name):
395 | new_flag = name.replace("_", "-")
396 | if kwargs.get(name):
397 | raise ValueError(
398 | "Should not set deprecated --{} and new --{}".format(deprecated_name, new_flag)
399 | )
400 | click.secho(
401 | "Using deprecated --{}, should use --{}".format(deprecated_name, new_flag), fg="red"
402 | )
403 | kwargs[name] = kwargs.pop(deprecated_name)
404 |
405 | # Deprecated old ignore files
406 | ignore_file_old_casing = kwargs.get("docstr-ignore-file")
407 | ignore_file_new_casing = kwargs.get("ignore_names_file")
408 |
409 | def _nondefault_or_existing_file(path):
410 | if path is None:
411 | return False
412 | return os.path.split(path)[-1] != ".docstr_coverage" or os.path.isfile(path)
413 |
414 | if _nondefault_or_existing_file(ignore_file_old_casing) or _nondefault_or_existing_file(
415 | ignore_file_new_casing
416 | ):
417 | click.secho(
418 | "Using deprecated ignore files."
419 | "We'll keep them supported for a while, "
420 | "but we recommend switching to a proper config file "
421 | "(see commands -C / --config)"
422 | )
423 |
424 |
425 | if __name__ == "__main__":
426 | execute()
427 |
--------------------------------------------------------------------------------
/docstr_coverage/config_file.py:
--------------------------------------------------------------------------------
1 | """This module is for conf file .docstr.yaml"""
2 | import os
3 | from typing import Any, Callable, Dict, List
4 |
5 | import click
6 | import yaml
7 |
8 |
9 | def set_config_defaults(ctx, param, value):
10 | """Update CLI option defaults in `ctx` to config file values
11 |
12 | Parameters
13 | ----------
14 | ctx: click.Context
15 | Click Context object
16 | param: click.Parameter
17 | Click Parameter object (assumed to be `config`)
18 | value: String
19 | Path to the configuration file
20 |
21 | Returns
22 | -------
23 | String
24 | Path to the configuration file"""
25 | if value is not None and os.path.exists(value):
26 | with open(value) as f:
27 | config_data = yaml.safe_load(f) or {}
28 | ctx.params["config_file"] = value
29 | # Resolve paths like Click would have with the `click.Path.resolve_path` kwarg
30 | _extract_non_default_list(
31 | config_data,
32 | ctx,
33 | "paths",
34 | lambda config_paths: tuple([os.path.realpath(path) for path in config_paths]),
35 | )
36 | _extract_non_default_list(config_data, ctx, "ignore_patterns", lambda x: x)
37 | # TODO This can be removed as part PR #52 (verbose counting).
38 | # Until then, this is for compatibility with docs
39 | # which require verbose in config-file to be an int
40 | if "verbose" in config_data:
41 | config_data["verbose"] = str(config_data["verbose"])
42 | ctx.default_map = config_data
43 |
44 | return value
45 |
46 |
47 | def _extract_non_default_list(
48 | config_data: Dict, ctx: click.Context, field: str, process: Callable[[List], Any]
49 | ) -> None:
50 | """Processes a field of the config file which should be used as the default value
51 | if not provided as a CLI argument
52 |
53 | The field is considered an optional list: If not present in the config data,
54 | calling this method has no effect.
55 | If a single value (as opposed to a list) is present in `config_data` for this field,
56 | a list based on only this value will be appended to the ctx.params.
57 |
58 | Parameters
59 | ----------
60 | config_data: Dict
61 | Parsed yaml config file
62 | ctx: click.Context
63 | Click Context object
64 | field: str
65 | Name of the field for which the value has to be extracted
66 | process: Callable
67 | A mapping function, allowing to modify or replace transfer the values
68 | present in the config file before storing them in ctx.params"""
69 | try:
70 | # Check if `field` was given in config file
71 | config_paths = config_data.pop(field)
72 | except KeyError:
73 | # No value for field was provided
74 | pass
75 | else:
76 | # Use config default if `field` was not provided as CLI argument
77 | if not ctx.params.get(field) and config_paths:
78 | if isinstance(config_paths, str):
79 | config_paths = [config_paths]
80 | ctx.params[field] = process(config_paths)
81 |
--------------------------------------------------------------------------------
/docstr_coverage/coverage.py:
--------------------------------------------------------------------------------
1 | """The central module for coverage collection and file-walking"""
2 |
3 | import os
4 | import re
5 | from ast import parse
6 | from typing import Dict, List, Optional, Tuple
7 |
8 | from tqdm import tqdm
9 |
10 | from docstr_coverage.ignore_config import IgnoreConfig
11 | from docstr_coverage.printers import LegacyPrinter
12 | from docstr_coverage.result_collection import File, FileStatus, ResultCollection
13 | from docstr_coverage.visitor import DocStringCoverageVisitor
14 |
15 |
16 | def _do_ignore_node(filename: str, base_name: str, node_name: str, ignore_names: tuple) -> bool:
17 | """Determine whether a node (identified by its file, base, and own names) should be ignored
18 |
19 | Parameters
20 | ----------
21 | filename: String
22 | Name of the file containing `node_name`
23 | base_name: String
24 | Name of the node's parent node
25 | node_name: String
26 | Name of the node within the file. Usually a function name, class name, or a method name. In
27 | the case of method names, `node_name` will be only the method's name, while `base_name` will
28 | be of the form "."
29 | ignore_names: Tuple[List[str], ...]
30 | Patterns of nodes to ignore. See :class:`docstr_coverage.ignore_config.IgnoreConfig`
31 |
32 | Returns
33 | -------
34 | Boolean
35 | True if the node should be ignored, else False"""
36 | filename = os.path.basename(filename).split(".")[0]
37 |
38 | for (file_regex, *name_regexes) in ignore_names:
39 | file_match = re.fullmatch(file_regex, filename)
40 | file_match = file_match.group() if file_match else None
41 |
42 | if file_match != filename:
43 | continue
44 |
45 | for name_regex in name_regexes:
46 | # Match on node name only
47 | name_match = re.fullmatch(name_regex, node_name)
48 | name_match = name_match.group() if name_match else None
49 |
50 | if name_match:
51 | return True
52 |
53 | # Match on node's period-delimited path: Its parent nodes (if any), plus the node name.
54 | # This enables targeting i.e. the `__init__` method of a particular class, whereas
55 | # the simple name match above would target `__init__` methods of all classes
56 | full_name_match = re.fullmatch(name_regex, "{}{}".format(base_name, node_name))
57 | full_name_match = full_name_match.group() if full_name_match else None
58 |
59 | if full_name_match:
60 | return True
61 | return False
62 |
63 |
64 | def _analyze_docstrings_on_node(
65 | base: str,
66 | node: Tuple[str, bool, Optional[str], List],
67 | filename,
68 | ignore_config: IgnoreConfig,
69 | result_storage: File,
70 | ):
71 | """Track the existence of a docstring for `node`, and accumulate stats regarding
72 | expected and encountered docstrings for `node` and its children (if any).
73 |
74 | Parameters
75 | ----------
76 | base: String
77 | The name of this node's parent node
78 | node: Tuple triple of (String, Boolean, List)
79 | Information describing a node. `node[0]` is the node's name.
80 | `node[1]` is True if the node was properly documented,
81 | else False. `node[3]` is a list containing the node's children
82 | as triples of the same form (if it had any)
83 | filename: String
84 | String containing the name of the file.
85 | ignore_config: IgnoreConfig
86 | Information about which docstrings are to be ignored.
87 | result_storage: File
88 | The result-collection.File instance on which the observed
89 | docstring presence should be stored."""
90 |
91 | name, has_doc, decorator, child_nodes = node
92 |
93 | ##################################################
94 | # Check Current Node
95 | ##################################################
96 |
97 | # Check for ignore status
98 | ignore_reason = None
99 | if ignore_config.skip_init and name == "__init__":
100 | ignore_reason = "skip-init set to True"
101 | elif (
102 | ignore_config.skip_magic
103 | and name.startswith("__")
104 | and name.endswith("__")
105 | and name != "__init__"
106 | ):
107 | ignore_reason = "skip-magic set to True"
108 | elif ignore_config.skip_class_def and "_" not in name and (name[0] == name[0].upper()):
109 | ignore_reason = "skip-class-def set to True"
110 | elif ignore_config.skip_private and name.startswith("_") and not name.startswith("__"):
111 | ignore_reason = "skip-private set to True"
112 | elif ignore_config.ignore_names and _do_ignore_node(
113 | filename, base, name, ignore_config.ignore_names
114 | ):
115 | ignore_reason = "matching ignore pattern"
116 | elif ignore_config.skip_deleter and decorator == "@deleter":
117 | ignore_reason = "skip-deleter set to True"
118 | elif ignore_config.skip_property and decorator == "@property":
119 | ignore_reason = "skip-property set to True"
120 | elif ignore_config.skip_setter and decorator == "@setter":
121 | ignore_reason = "skip-setter set to True"
122 |
123 | # Set Result
124 | node_identifier = str(base) + str(name)
125 | result_storage.collect_docstring(
126 | identifier=node_identifier, has_docstring=has_doc, ignore_reason=ignore_reason
127 | )
128 |
129 | ##################################################
130 | # Check Child Nodes
131 | ##################################################
132 | for _symbol in child_nodes:
133 | _analyze_docstrings_on_node("%s." % name, _symbol, filename, ignore_config, result_storage)
134 |
135 |
136 | def get_docstring_coverage(
137 | filenames: list,
138 | skip_magic: bool = False,
139 | skip_file_docstring: bool = False,
140 | skip_init: bool = False,
141 | skip_class_def: bool = False,
142 | skip_private: bool = False,
143 | verbose: int = 0,
144 | ignore_names: Tuple[List[str], ...] = (),
145 | ) -> Tuple[Dict, Dict]:
146 | """Checks contents of `filenames` for missing docstrings, and produces a report
147 | detailing docstring status.
148 |
149 | *Note*:
150 | For a method with a more expressive return type,
151 | you may want to try the experimental `docstr_coverage.analyze`
152 | function.
153 |
154 | Parameters
155 | ----------
156 | filenames: List
157 | List of filename strings that are absolute or relative paths
158 | skip_magic: Boolean, default=False
159 | If True, skips all magic methods (double-underscore-prefixed),
160 | except '__init__' and does not include them in the report
161 | skip_file_docstring: Boolean, default=False
162 | If True, skips check for a module-level docstring
163 | skip_init: Boolean, default=False
164 | If True, skips methods named '__init__' and does not include
165 | them in the report
166 | skip_class_def: Boolean, default=False
167 | If True, skips class definitions and does not include them in the report.
168 | If this is True, the class's methods will still be checked
169 | skip_private: Boolean, default=False
170 | If True, skips function definitions beginning with a single underscore and does
171 | not include them in the report.
172 | verbose: Int in [0, 1, 2, 3], default=0
173 | 0) No printing.
174 | 1) Print total stats only.
175 | 2) Print stats for all files.
176 | 3) Print missing docstrings for all files.
177 | ignore_names: Tuple[List[str], ...], default=()
178 | Patterns to ignore when checking documentation. Each list in `ignore_names` defines a
179 | different pattern to be ignored. The first element in each list is the regular
180 | expression for matching filenames. All remaining arguments in each list are
181 | regexes for matching names of functions/classes. A node is ignored if it
182 | matches the filename regex and at least one of the remaining regexes
183 |
184 | Returns
185 | -------
186 | Dict
187 | Links filename keys to a dict of stats for that filename. Example:
188 |
189 | >>> {
190 | ... '': {
191 | ... 'missing': ['', '...'],
192 | ... 'module_doc': '',
193 | ... 'missing_count': '',
194 | ... 'needed_count': '',
195 | ... 'coverage': '',
196 | ... 'empty': ''
197 | ... }, ...
198 | ... }
199 | Dict
200 | Total summary stats for all files analyzed. Example:
201 |
202 | >>> {
203 | ... 'missing_count': '',
204 | ... 'needed_count': '',
205 | ... 'coverage': ''
206 | ... }"""
207 | ignore_config = IgnoreConfig(
208 | skip_magic=skip_magic,
209 | skip_file_docstring=skip_file_docstring,
210 | skip_init=skip_init,
211 | skip_class_def=skip_class_def,
212 | skip_private=skip_private,
213 | ignore_names=ignore_names,
214 | )
215 | results = analyze(filenames, ignore_config)
216 | LegacyPrinter(results, verbosity=verbose, ignore_config=ignore_config).print_to_stdout()
217 | return results.to_legacy()
218 |
219 |
220 | def analyze(
221 | filenames: list, ignore_config: IgnoreConfig = IgnoreConfig(), show_progress=True
222 | ) -> ResultCollection:
223 | """EXPERIMENTAL: More expressive alternative to `get_docstring_coverage`.
224 |
225 | Checks contents of `filenames` for missing docstrings, and produces a report detailing
226 | docstring status
227 |
228 | Note that this method, as well as its parameters and return types
229 | are still experimental and may change in future versions.
230 |
231 | Parameters
232 | ----------
233 | filenames: List
234 | List of filename strings that are absolute or relative paths
235 |
236 | ignore_config: IgnoreConfig
237 | Information about which docstrings are to be ignored
238 |
239 | show_progress: Boolean, default=True
240 | If True, prints a progress bar to stdout
241 |
242 | Returns
243 | -------
244 | ResultCollection
245 | The collected information about docstring presence"""
246 | results = ResultCollection()
247 |
248 | iterator = iter(filenames)
249 | if show_progress:
250 | iterator = tqdm(
251 | iterator,
252 | desc="Checking python files",
253 | unit="files",
254 | unit_scale=True,
255 | total=len(filenames),
256 | )
257 |
258 | for filename in iterator:
259 | file_result = results.get_file(file_path=filename)
260 |
261 | ##################################################
262 | # Read and Parse Source
263 | ##################################################
264 | with open(filename, "r", encoding="utf-8") as f:
265 | source_tree = f.read()
266 |
267 | doc_visitor = DocStringCoverageVisitor(filename=filename)
268 | doc_visitor.visit(parse(source_tree))
269 | _tree = doc_visitor.tree[0]
270 |
271 | ##################################################
272 | # Process Results
273 | ##################################################
274 |
275 | # _tree contains [, , ]
276 | if (not _tree[0]) and (not _tree[1]):
277 | if not ignore_config.skip_file_docstring:
278 | file_result.collect_module_docstring(has_docstring=False)
279 | else:
280 | file_result.collect_module_docstring(
281 | has_docstring=False, ignore_reason="--skip-file-docstring=True"
282 | )
283 | elif _tree[1]:
284 | file_result.status = FileStatus.EMPTY
285 | else:
286 | file_result.collect_module_docstring(bool(_tree[0]))
287 |
288 | # Recursively traverse through functions and classes
289 | for symbol in _tree[-1]:
290 | _analyze_docstrings_on_node("", symbol, filename, ignore_config, file_result)
291 |
292 | return results
293 |
--------------------------------------------------------------------------------
/docstr_coverage/ignore_config.py:
--------------------------------------------------------------------------------
1 | """Module containing a single utility data class: IgnoreConfig"""
2 | from typing import List, Tuple
3 |
4 |
5 | class IgnoreConfig:
6 | """Data class storing information about docstring types to ignore when aggregating coverage"""
7 |
8 | def __init__(
9 | self,
10 | ignore_names: Tuple[List[str], ...] = (),
11 | skip_magic: bool = False,
12 | skip_file_docstring: bool = False,
13 | skip_init: bool = False,
14 | skip_class_def: bool = False,
15 | skip_private: bool = False,
16 | skip_property: bool = False,
17 | skip_setter: bool = True,
18 | skip_deleter: bool = True,
19 | ):
20 | self._ignore_names = ignore_names
21 | self._skip_magic = skip_magic
22 | self._skip_file_docstring = skip_file_docstring
23 | self._skip_init = skip_init
24 | self._skip_class_def = skip_class_def
25 | self._skip_private = skip_private
26 | self._skip_property = skip_property
27 | self._skip_setter = skip_setter
28 | self._skip_deleter = skip_deleter
29 |
30 | @property
31 | def ignore_names(self):
32 | """Patterns to ignore when checking documentation. Each list in `ignore_names` defines a
33 | different pattern to be ignored. The first element in each list is the regular expression
34 | for matching filenames. All remaining arguments in each list are regexes for matching names
35 | of functions/classes. A node is ignored if it matches the filename regex and at least one
36 | of the remaining regexes"""
37 | return self._ignore_names
38 |
39 | @property
40 | def skip_magic(self):
41 | """If True, skip all magic methods (methods with both leading and trailing double
42 | underscores), except `__init__` and exclude them from the report"""
43 | return self._skip_magic
44 |
45 | @property
46 | def skip_file_docstring(self):
47 | """If True, skip check for a module-level docstring"""
48 | return self._skip_file_docstring
49 |
50 | @property
51 | def skip_init(self):
52 | """If True, skip methods named `__init__` and exclude them from the report"""
53 | return self._skip_init
54 |
55 | @property
56 | def skip_class_def(self):
57 | """If True, skip class definition docstrings and exclude them from the report. If this is
58 | True, the class's methods will still be checked"""
59 | return self._skip_class_def
60 |
61 | @property
62 | def skip_private(self):
63 | """If True, skip function definitions beginning with a single underscore."""
64 | return self._skip_private
65 |
66 | @property
67 | def skip_property(self):
68 | """If True, skip nodes with `@property` decorator."""
69 | return self._skip_property
70 |
71 | @property
72 | def skip_setter(self):
73 | """If True, skip nodes with `@setter` decorator."""
74 | return self._skip_setter
75 |
76 | @property
77 | def skip_deleter(self):
78 | """If True, skip nodes with `@deleter` decorator."""
79 | return self._skip_deleter
80 |
--------------------------------------------------------------------------------
/docstr_coverage/printers.py:
--------------------------------------------------------------------------------
1 | """All logic used to print a recorded ResultCollection to stdout.
2 | Currently, this module is in BETA and its interface may change in future versions."""
3 | import logging
4 | from abc import ABC, abstractmethod
5 | from dataclasses import dataclass
6 | from typing import List, Optional, Tuple, Union
7 |
8 | from docstr_coverage.ignore_config import IgnoreConfig
9 | from docstr_coverage.result_collection import (
10 | AggregatedCount,
11 | File,
12 | FileStatus,
13 | ResultCollection,
14 | )
15 |
16 | _GRADES = (
17 | ("AMAZING! Your docstrings are truly a wonder to behold!", 100),
18 | ("Excellent", 92),
19 | ("Great", 85),
20 | ("Very good", 70),
21 | ("Good", 60),
22 | ("Not bad", 40),
23 | ("Not good", 25),
24 | ("Extremely poor", 10),
25 | ("Not documented at all", 2),
26 | ("Do you even docstring?", 0),
27 | )
28 |
29 | logger = logging.getLogger(__name__)
30 | logging.basicConfig(level=logging.INFO, format="%(message)s")
31 |
32 |
33 | @dataclass(frozen=True)
34 | class IgnoredNode:
35 | """Data Structure for nodes that was ignored in checking."""
36 |
37 | identifier: str
38 | reason: str
39 |
40 |
41 | @dataclass(frozen=True)
42 | class FileCoverageStat:
43 | """Data Structure of coverage info about one file.
44 |
45 | For `verbosity` with value:
46 | * `2` - Fields `coverage`, `found`, `missing`, `needed` and `path`.
47 | * `3` - Fields with `verbosity` `2` and `nodes_without_docstring`.
48 | * `4` - Fields with `verbosity` `3` and `is_empty`, `nodes_with_docstring`,
49 | `ignored_nodes`
50 | """
51 |
52 | coverage: float
53 | found: int
54 | missing: int
55 | needed: int
56 | path: str
57 | ignored_nodes: Optional[Tuple[IgnoredNode, ...]]
58 | is_empty: Optional[Union[bool]]
59 | nodes_with_docstring: Optional[Tuple[str, ...]]
60 | nodes_without_docstring: Optional[Tuple[str, ...]]
61 |
62 |
63 | @dataclass(frozen=True)
64 | class OverallCoverageStat:
65 | """Data Structure of coverage statistic."""
66 |
67 | found: int
68 | grade: str
69 | is_skip_class_def: bool
70 | is_skip_file_docstring: bool
71 | is_skip_init: bool
72 | is_skip_magic: bool
73 | is_skip_private: bool
74 | missing: int
75 | needed: int
76 | num_empty_files: int
77 | num_files: int
78 | total_coverage: float
79 |
80 |
81 | class Printer(ABC):
82 | """Base abstract superclass for printing coverage results.
83 |
84 | It provides coverage results in data structures (`OverallCoverageStat`, `FileCoverageStat` and
85 | `IgnoredNode`) and abstract methods for implementing type of displaying and saving in file
86 | statistic data.
87 |
88 | In heir classes you can use `overall_coverage_stat` and `overall_files_coverage_stat`
89 | attributes. Depends of given `verbosity` some data can be `None`."""
90 |
91 | def __init__(
92 | self,
93 | results: ResultCollection,
94 | verbosity: int,
95 | ignore_config: IgnoreConfig = IgnoreConfig(),
96 | ):
97 | """
98 | Parameters
99 | ----------
100 | results: ResultCollection
101 | Coverage analyze results.
102 | verbosity: int
103 | Verbosity identifier.
104 | ignore_config: IgnoreConfig
105 | Config with ignoring setups.
106 | """
107 | self.verbosity: int = verbosity
108 | self.ignore_config: IgnoreConfig = ignore_config
109 | self.results: ResultCollection = results
110 | self.__overall_coverage_stat: Optional[Union[OverallCoverageStat, float]] = None
111 | self.__overall_files_coverage_stat: Optional[List[FileCoverageStat]] = None
112 |
113 | @property
114 | def overall_coverage_stat(self) -> Union[OverallCoverageStat, float]:
115 | """Getting full coverage statistic.
116 |
117 | For `verbosity` with value:
118 | * `0` - Only `total_coverage` value returning.
119 | * `1` - All fields, except `files_info`.
120 | * `2` - All fields."""
121 | if self.__overall_coverage_stat is None:
122 | count: AggregatedCount = self.results.count_aggregate()
123 |
124 | if self.verbosity >= 1:
125 |
126 | self.__overall_coverage_stat = OverallCoverageStat(
127 | found=count.found,
128 | grade=next(
129 | message
130 | for message, grade_threshold in _GRADES
131 | if grade_threshold <= count.coverage()
132 | ),
133 | is_skip_class_def=self.ignore_config.skip_class_def,
134 | is_skip_file_docstring=self.ignore_config.skip_file_docstring,
135 | is_skip_init=self.ignore_config.skip_init,
136 | is_skip_magic=self.ignore_config.skip_magic,
137 | is_skip_private=self.ignore_config.skip_private,
138 | missing=count.missing,
139 | needed=count.needed,
140 | num_empty_files=count.num_empty_files,
141 | num_files=count.num_files,
142 | total_coverage=count.coverage(),
143 | )
144 |
145 | else:
146 | self.__overall_coverage_stat = count.coverage()
147 |
148 | return self.__overall_coverage_stat
149 |
150 | @property
151 | def overall_files_coverage_stat(self) -> Optional[List[FileCoverageStat]]:
152 | """Getting coverage statistics for files.
153 |
154 | For `verbosity` with value:
155 | * `2` - Fields `coverage`, `found`, `missing`, `needed` and `path`.
156 | * `3` - Fields with `verbosity` `2` and `nodes_without_docstring`.
157 | * `4` - Fields with `verbosity` `3` and `is_empty`, `nodes_with_docstring`,
158 | `ignored_nodes`
159 |
160 | Returns
161 | -------
162 | List[FileCoverageStat]
163 | Coverage info about all checked files."""
164 | if self.__overall_files_coverage_stat is None and self.verbosity >= 2:
165 | overall_files_coverage_stat: List[FileCoverageStat] = []
166 | for file_path, file_info in self.results.files():
167 |
168 | file_path: str
169 | file_info: File
170 | nodes_without_docstring: Optional[Tuple[str, ...]]
171 | is_empty: Optional[bool]
172 | nodes_with_docstring: Optional[Tuple[str, ...]]
173 | ignored_nodes: Optional[Tuple[IgnoredNode, ...]]
174 |
175 | if self.verbosity >= 3:
176 | nodes_without_docstring = tuple(
177 | expected_docstring.node_identifier
178 | for expected_docstring in file_info._expected_docstrings
179 | if not expected_docstring.has_docstring
180 | and not expected_docstring.ignore_reason
181 | )
182 | else:
183 | nodes_without_docstring = None
184 |
185 | if self.verbosity >= 4:
186 | is_empty = file_info.status == FileStatus.EMPTY
187 | nodes_with_docstring = tuple(
188 | expected_docstring.node_identifier
189 | for expected_docstring in file_info._expected_docstrings
190 | if expected_docstring.has_docstring and not expected_docstring.ignore_reason
191 | )
192 | ignored_nodes = tuple(
193 | IgnoredNode(
194 | identifier=expected_docstring.node_identifier,
195 | reason=expected_docstring.ignore_reason,
196 | )
197 | for expected_docstring in file_info._expected_docstrings
198 | if expected_docstring.ignore_reason is not None
199 | )
200 | else:
201 | is_empty = None
202 | nodes_with_docstring = None
203 | ignored_nodes = None
204 |
205 | count = file_info.count_aggregate()
206 | overall_files_coverage_stat.append(
207 | FileCoverageStat(
208 | coverage=count.coverage(),
209 | found=count.found,
210 | missing=count.missing,
211 | needed=count.needed,
212 | path=file_path,
213 | ignored_nodes=ignored_nodes,
214 | is_empty=is_empty,
215 | nodes_with_docstring=nodes_with_docstring,
216 | nodes_without_docstring=nodes_without_docstring,
217 | )
218 | )
219 | self.__overall_files_coverage_stat = overall_files_coverage_stat
220 |
221 | return self.__overall_files_coverage_stat
222 |
223 | @abstractmethod
224 | def print_to_stdout(self) -> None:
225 | """Providing how to print coverage results."""
226 | pass
227 |
228 | @abstractmethod
229 | def save_to_file(self, path: Optional[str] = None) -> None:
230 | """Providing how to save coverage results in file.
231 |
232 | Parameters
233 | ----------
234 | path: Optional[str]
235 | Path to file with coverage results.
236 | """
237 | pass
238 |
239 |
240 | class LegacyPrinter(Printer):
241 | """Printer for legacy format."""
242 |
243 | def print_to_stdout(self) -> None:
244 | for line in self._generate_string().split("\n"):
245 | logger.info(line)
246 |
247 | def save_to_file(self, path: Optional[str] = None) -> None:
248 | if path is None:
249 | path = "./coverage-results.txt"
250 | with open(path, "w") as wf:
251 | wf.write(self._generate_string())
252 |
253 | def _generate_string(self) -> str:
254 | final_string: str = ""
255 |
256 | if self.overall_files_coverage_stat is not None:
257 | final_string += self._generate_file_stat_string()
258 | final_string += "\n"
259 | final_string += self._generate_overall_stat_string()
260 |
261 | return final_string
262 |
263 | def _generate_file_stat_string(self):
264 | final_string: str = ""
265 | for file_coverage_stat in self.overall_files_coverage_stat:
266 |
267 | file_string: str = 'File: "{0}"\n'.format(file_coverage_stat.path)
268 |
269 | if file_coverage_stat.is_empty is not None and file_coverage_stat.is_empty is True:
270 | file_string += " - File is empty\n"
271 |
272 | if file_coverage_stat.nodes_with_docstring is not None:
273 | for node_identifier in file_coverage_stat.nodes_with_docstring:
274 | file_string += " - Found docstring for `{0}`\n".format(
275 | node_identifier,
276 | )
277 |
278 | if file_coverage_stat.ignored_nodes is not None:
279 | for ignored_node in file_coverage_stat.ignored_nodes:
280 | file_string += " - Ignored `{0}`: reason: `{1}`\n".format(
281 | ignored_node.identifier,
282 | ignored_node.reason,
283 | )
284 |
285 | if file_coverage_stat.nodes_without_docstring is not None:
286 | for node_identifier in file_coverage_stat.nodes_without_docstring:
287 | if node_identifier == "module docstring":
288 | file_string += " - No module docstring\n"
289 | else:
290 | file_string += " - No docstring for `{0}`\n".format(node_identifier)
291 |
292 | file_string += " Needed: %s; Found: %s; Missing: %s; Coverage: %.1f%%" % (
293 | file_coverage_stat.needed,
294 | file_coverage_stat.found,
295 | file_coverage_stat.missing,
296 | file_coverage_stat.coverage,
297 | )
298 |
299 | final_string += "\n" + file_string + "\n"
300 |
301 | return final_string + "\n"
302 |
303 | def _generate_overall_stat_string(self) -> str:
304 | if isinstance(self.overall_coverage_stat, float):
305 | return str(self.overall_coverage_stat)
306 |
307 | prefix: str = ""
308 |
309 | if self.overall_coverage_stat.num_empty_files > 0:
310 | prefix += " (%s files are empty)" % self.overall_coverage_stat.num_empty_files
311 |
312 | if self.overall_coverage_stat.is_skip_magic:
313 | prefix += " (skipped all non-init magic methods)"
314 |
315 | if self.overall_coverage_stat.is_skip_file_docstring:
316 | prefix += " (skipped file-level docstrings)"
317 |
318 | if self.overall_coverage_stat.is_skip_init:
319 | prefix += " (skipped __init__ methods)"
320 |
321 | if self.overall_coverage_stat.is_skip_class_def:
322 | prefix += " (skipped class definitions)"
323 |
324 | if self.overall_coverage_stat.is_skip_private:
325 | prefix += " (skipped private methods)"
326 |
327 | final_string: str = ""
328 |
329 | if self.overall_coverage_stat.num_files > 1:
330 | final_string += "Overall statistics for %s files%s:\n" % (
331 | self.overall_coverage_stat.num_files,
332 | prefix,
333 | )
334 | else:
335 | final_string += "Overall statistics%s:\n" % prefix
336 |
337 | final_string += "Needed: {} - Found: {} - Missing: {}\n".format(
338 | self.overall_coverage_stat.needed,
339 | self.overall_coverage_stat.found,
340 | self.overall_coverage_stat.missing,
341 | )
342 |
343 | final_string += "Total coverage: {:.1f}% - Grade: {}".format(
344 | self.overall_coverage_stat.total_coverage,
345 | self.overall_coverage_stat.grade,
346 | )
347 |
348 | return final_string
349 |
350 |
351 | class MarkdownPrinter(LegacyPrinter):
352 | """Printer for Markdown format."""
353 |
354 | def save_to_file(self, path: Optional[str] = None) -> None:
355 | if path is None:
356 | path = "./coverage-results.md"
357 | with open(path, "w") as wf:
358 | wf.write(self._generate_string())
359 |
360 | def _generate_file_stat_string(self) -> str:
361 | final_string: str = ""
362 | for file_coverage_stat in self.overall_files_coverage_stat:
363 |
364 | file_string: str = "**File**: `{0}`\n".format(file_coverage_stat.path)
365 |
366 | if file_coverage_stat.is_empty is not None and file_coverage_stat.is_empty is True:
367 | file_string += "- File is empty\n"
368 |
369 | if file_coverage_stat.nodes_with_docstring is not None:
370 | for node_identifier in file_coverage_stat.nodes_with_docstring:
371 | file_string += "- Found docstring for `{0}`\n".format(
372 | node_identifier,
373 | )
374 |
375 | if file_coverage_stat.ignored_nodes is not None:
376 | for ignored_node in file_coverage_stat.ignored_nodes:
377 | file_string += "- Ignored `{0}`: reason: `{1}`\n".format(
378 | ignored_node.identifier,
379 | ignored_node.reason,
380 | )
381 |
382 | if file_coverage_stat.nodes_without_docstring is not None:
383 | for node_identifier in file_coverage_stat.nodes_without_docstring:
384 | if node_identifier == "module docstring":
385 | file_string += "- No module docstring\n"
386 | else:
387 | file_string += "- No docstring for `{0}`\n".format(node_identifier)
388 |
389 | file_string += "\n"
390 |
391 | file_string += self._generate_markdown_table(
392 | ("Needed", "Found", "Missing", "Coverage"),
393 | (
394 | (
395 | file_coverage_stat.needed,
396 | file_coverage_stat.found,
397 | file_coverage_stat.missing,
398 | "{:.1f}%".format(file_coverage_stat.coverage),
399 | ),
400 | ),
401 | )
402 |
403 | if final_string == "":
404 | final_string += file_string + "\n"
405 | else:
406 | final_string += "\n" + file_string + "\n"
407 |
408 | return final_string + "\n"
409 |
410 | def _generate_overall_stat_string(self) -> str:
411 | if isinstance(self.overall_coverage_stat, float):
412 | return str(self.overall_coverage_stat)
413 |
414 | final_string: str = "## Overall statistics\n"
415 |
416 | if self.overall_coverage_stat.num_files > 1:
417 | final_string += "Files number: **{}**\n".format(self.overall_coverage_stat.num_files)
418 |
419 | final_string += "\n"
420 |
421 | final_string += "Total coverage: **{:.1f}%**\n".format(
422 | self.overall_coverage_stat.total_coverage,
423 | )
424 |
425 | final_string += "\n"
426 |
427 | final_string += "Grade: **{}**\n".format(self.overall_coverage_stat.grade)
428 |
429 | if self.overall_coverage_stat.num_empty_files > 0:
430 | final_string += "- %s files are empty\n" % self.overall_coverage_stat.num_empty_files
431 |
432 | if self.overall_coverage_stat.is_skip_magic:
433 | final_string += "- skipped all non-init magic methods\n"
434 |
435 | if self.overall_coverage_stat.is_skip_file_docstring:
436 | final_string += "- skipped file-level docstrings\n"
437 |
438 | if self.overall_coverage_stat.is_skip_init:
439 | final_string += "- skipped __init__ methods\n"
440 |
441 | if self.overall_coverage_stat.is_skip_class_def:
442 | final_string += "- skipped class definitions\n"
443 |
444 | if self.overall_coverage_stat.is_skip_private:
445 | final_string += "- skipped private methods\n"
446 |
447 | final_string += "\n"
448 |
449 | final_string += self._generate_markdown_table(
450 | ("Needed", "Found", "Missing"),
451 | (
452 | (
453 | self.overall_coverage_stat.needed,
454 | self.overall_coverage_stat.found,
455 | self.overall_coverage_stat.missing,
456 | ),
457 | ),
458 | )
459 |
460 | return final_string
461 |
462 | def _generate_markdown_table(
463 | self,
464 | cols: Tuple[str, ...],
465 | rows: Tuple[Tuple[Union[str, int, float]], ...],
466 | ) -> str:
467 | """Generate markdown table.
468 |
469 | Using:
470 | >>> self._generate_markdown_table(
471 | ... cols=("Needed", "Found", "Missing"),
472 | ... vals=(
473 | ... (10, 20, "65.5%"),
474 | ... (30, 40, "99.9%")
475 | ... )
476 | ... )
477 | | Needed | Found | Missing |
478 | |---|---|---|
479 | | 10 | 20 | 65.5% |
480 | | 30 | 40 | 99.9% |
481 |
482 | Parameters
483 | ----------
484 | cols: Tuple[str, ...]
485 | Table columns
486 | rows: Tuple[Tuple[Union[str, int, float]], ...]
487 | Column values
488 |
489 | Returns
490 | -------
491 | str
492 | Generated table.
493 | """
494 | if not all(len(v) == len(cols) for v in rows):
495 | raise ValueError("Col num not equal to cols value")
496 | final_string: str = ""
497 |
498 | for col in cols:
499 | final_string += "| {} ".format(col)
500 | final_string += "|\n"
501 |
502 | for _ in range(len(cols)):
503 | final_string += "|---"
504 | final_string += "|\n"
505 |
506 | for row in rows:
507 | for value in row:
508 | final_string += "| {} ".format(value)
509 | final_string += "|"
510 |
511 | return final_string
512 |
--------------------------------------------------------------------------------
/docstr_coverage/result_collection.py:
--------------------------------------------------------------------------------
1 | """Module containing the classes required to collect and aggregate docstring information.
2 |
3 | Currently, this module is in BETA and its interface may change in future versions."""
4 | import abc
5 | import enum
6 | import functools
7 | import operator
8 | from typing import Optional
9 |
10 |
11 | class ResultCollection:
12 | """A result collection contains information about the presence of docstrings collected during
13 | the walk through the files. From the `ResultCollection`, information about missing docstrings
14 | for inspected files can be retrieved, and summary metrics (e.g. coverage) can be extracted
15 | without having to walk over the files again."""
16 |
17 | def __init__(self):
18 | self._files = dict()
19 |
20 | def get_file(self, file_path):
21 | """Provides access to the docstring information for specific files.
22 | This primarily targets information collection phase
23 | (thus the docstr-coverage internal process).
24 |
25 | If no file with the provided name is on record yet, a new empty `File` information instance
26 | is created and returned.
27 |
28 | Parameters
29 | ----------
30 | file_path: String
31 | The path of the file for which the information is requested.
32 |
33 | Returns
34 | -------
35 | File
36 | The file (information) instance used to track docstring information"""
37 | try:
38 | return self._files[file_path]
39 | except KeyError:
40 | file = File()
41 | self._files[file_path] = file
42 | return file
43 |
44 | def count_aggregate(self):
45 | """Walks through all the tracked files in this result collection, and counts overall
46 | statistics, such as #missing docstring.
47 |
48 | Returns
49 | -------
50 | AggregatedCount
51 | A count instance containing a range of docstring counts."""
52 | counts = (file.count_aggregate() for file in self._files.values())
53 | return functools.reduce(operator.add, counts, AggregatedCount())
54 |
55 | def files(self):
56 | """View of all (file-name, file-info) tuples in this result collection"""
57 | return self._files.items()
58 |
59 | def to_legacy(self):
60 | """Converts the information in this `ResultCollection` into the less expressive dictionary
61 | of counts used since the early versions of docstr-coverage.
62 |
63 | Returns
64 | -------
65 | Dict
66 | Links filename keys to a dict of stats for that filename. Example:
67 |
68 | >>> {
69 | ... '': {
70 | ... 'missing': ['', '...'],
71 | ... 'module_doc': '',
72 | ... 'missing_count': '',
73 | ... 'needed_count': '',
74 | ... 'coverage': '',
75 | ... 'empty': ''
76 | ... }, ...
77 | ... }
78 | Dict
79 | Total summary stats for all files analyzed. Example:
80 |
81 | >>> {
82 | ... 'missing_count': '',
83 | ... 'needed_count': '',
84 | ... 'coverage': ''
85 | ... }
86 | """
87 |
88 | file_results = dict()
89 | for file_path, file in self.files():
90 | missing_list = [
91 | e.node_identifier
92 | for e in file.expected_docstrings()
93 | if not (
94 | e.ignore_reason or e.has_docstring or e.node_identifier == "module docstring"
95 | )
96 | ]
97 | has_module_doc = (
98 | len(
99 | [
100 | e
101 | for e in file.expected_docstrings()
102 | if e.node_identifier == "module docstring" and e.has_docstring
103 | ]
104 | )
105 | > 0
106 | )
107 | count = file.count_aggregate()
108 | file_results[file_path] = {
109 | "missing": missing_list,
110 | "module_doc": has_module_doc,
111 | "missing_count": count.missing,
112 | "needed_count": count.needed,
113 | "coverage": count.coverage(),
114 | "empty": count.is_empty,
115 | }
116 | total_count = self.count_aggregate()
117 | total_results = {
118 | "missing_count": total_count.missing,
119 | "needed_count": total_count.needed,
120 | "coverage": total_count.coverage(),
121 | }
122 | return file_results, total_results
123 |
124 |
125 | class FileStatus(enum.Enum):
126 | """Upon inspection by docstr-coverage,
127 | every file is assigned exactly one of the states from this enum."""
128 |
129 | ANALYZED = (1,)
130 | EMPTY = 2
131 | # Note: We may eventually add IGNORED here, but this will require some more refactoring
132 |
133 |
134 | class File:
135 | """The information about docstrings for a single file."""
136 |
137 | def __init__(self) -> None:
138 | super().__init__()
139 | self._expected_docstrings = []
140 | self._status = FileStatus.ANALYZED
141 |
142 | def collect_docstring(self, identifier: str, has_docstring: bool, ignore_reason: str = None):
143 | """Used internally by docstr-coverage to collect the status of a single, expected docstring.
144 |
145 | For module docstrings, use `collect_module_docstring(...)` instead of this method.
146 |
147 | Parameters
148 | ----------
149 | identifier: String
150 | The identifier of the docstring (typically qualified function/class name)
151 | has_docstring: bool
152 | True if and only if the docstring was present
153 | ignore_reason: Optional[str]
154 | Used to indicate that the docstring should be ignored (independent of its presence)"""
155 | self._expected_docstrings.append(
156 | ExpectedDocstring(
157 | node_identifier=identifier, has_docstring=has_docstring, ignore_reason=ignore_reason
158 | )
159 | )
160 |
161 | def collect_module_docstring(self, has_docstring: bool, ignore_reason: str = None):
162 | """Used internally by docstr-coverage to collect the status of a module docstring.
163 |
164 | Parameters
165 | ----------
166 | has_docstring: bool
167 | True if and only if the docstring was present
168 | ignore_reason: Optional[str]
169 | Used to indicate that the docstring should be ignored (independent of its presence)"""
170 | self.collect_docstring(
171 | identifier="module docstring", has_docstring=has_docstring, ignore_reason=ignore_reason
172 | )
173 |
174 | def expected_docstrings(self):
175 | """A generator, iterating over all reported (present or missing) docstrings in this file"""
176 | return iter(self._expected_docstrings)
177 |
178 | @property
179 | def status(self) -> FileStatus:
180 | return self._status
181 |
182 | @status.setter
183 | def status(self, status):
184 | """Method used internally by docstr-coverage.
185 | The default file status is ANALYZED. To change this (e.g. to EMPTY),
186 | this method has to be called.
187 |
188 | Parameters
189 | ----------
190 | status: FileStatus
191 | The status for this file to record"""
192 | self._status = status
193 |
194 | def count_aggregate(self):
195 | """Walks through all docstring reports of this file and counts them by state
196 | (e.g. the #missing).
197 |
198 | Returns
199 | -------
200 | FileCount
201 | A count instance containing a range of docstring counts."""
202 | count = FileCount()
203 | if self._status == FileStatus.EMPTY:
204 | count.found_empty_file()
205 | else:
206 | for expd in self._expected_docstrings:
207 | if expd.ignore_reason:
208 | pass # Ignores will be counted in a future version
209 | elif expd.has_docstring:
210 | count.found_needed_docstr()
211 | else:
212 | count.missed_needed_docstring()
213 | return count
214 |
215 |
216 | class ExpectedDocstring:
217 | """A class containing information about a single docstring and its presence"""
218 |
219 | def __init__(
220 | self, node_identifier: str, has_docstring: bool, ignore_reason: Optional[str]
221 | ) -> None:
222 | super().__init__()
223 | self.node_identifier = node_identifier
224 | self.has_docstring = has_docstring
225 | self.ignore_reason = ignore_reason
226 |
227 |
228 | class _DocstrCount(abc.ABC):
229 | """ABSTRACT superclass of classes used to count docstrings by presence.
230 | See subclasses `AggregatedCount` and `FileCount`.
231 |
232 | Do not directly create instances of this abstract superclass (even though
233 | it has no abstract methods)."""
234 |
235 | def __init__(self, needed: int, found: int, missing: int):
236 | # Note: In the future, we'll add `self.ignored` here
237 | self.needed = needed
238 | self.found = found
239 | self.missing = missing
240 |
241 | def coverage(self):
242 | """Calculates the coverage in percent, given the counts recorded in self.
243 | If no docstrings were needed, the presence is reported as 100%."""
244 | try:
245 | return self.found * 100 / self.needed
246 | except ZeroDivisionError:
247 | return 100.0
248 |
249 |
250 | class AggregatedCount(_DocstrCount):
251 | """Counts of docstrings by presence, such as #missing, representing a list of files"""
252 |
253 | def __init__(
254 | self,
255 | num_files: int = 0,
256 | num_empty_files: int = 0,
257 | needed: int = 0,
258 | found: int = 0,
259 | missing: int = 0,
260 | ):
261 | super().__init__(needed=needed, found=found, missing=missing)
262 | self.num_files = num_files
263 | self.num_empty_files = num_empty_files
264 |
265 | def __add__(self, other):
266 | if isinstance(other, _DocstrCount):
267 | aggregated = AggregatedCount(
268 | needed=self.needed + other.needed,
269 | found=self.found + other.found,
270 | missing=self.missing + other.missing,
271 | )
272 | if isinstance(other, AggregatedCount):
273 | aggregated.num_files = self.num_files + other.num_files
274 | aggregated.num_empty_files = self.num_empty_files + other.num_empty_files
275 | elif isinstance(other, FileCount):
276 | aggregated.num_files = self.num_files + 1
277 | aggregated.num_empty_files = self.num_empty_files + int(other.is_empty)
278 | else:
279 | raise NotImplementedError(
280 | "Received unexpected DocstrCount subtype ({}). "
281 | "Please report to docstr-coverage issue tracker.".format(type(other))
282 | )
283 | return aggregated
284 | else:
285 | # Chosen NotImplementedError over TypeError as specified in
286 | # https://docs.python.org/3/reference/datamodel.html#object.__add__ :
287 | # "If one of those methods does not support the operation with the supplied arguments,
288 | # it should return NotImplemented."
289 | raise NotImplementedError(
290 | "Can only add _Count and _AggregatedCount instances to a _AggregatedCount instance"
291 | " but received {}".format(type(other))
292 | )
293 |
294 | def __eq__(self, other):
295 | if isinstance(other, AggregatedCount):
296 | return (
297 | self.num_files == other.num_files
298 | and self.num_empty_files == other.num_empty_files
299 | and self.needed == other.needed
300 | and self.found == other.found
301 | and self.missing == other.missing
302 | )
303 | return False
304 |
305 |
306 | class FileCount(_DocstrCount):
307 | """Counts of docstrings by presence, such as #missing, representing a single file"""
308 |
309 | def __init__(self) -> None:
310 | super().__init__(needed=0, found=0, missing=0)
311 | self.is_empty = False
312 |
313 | def found_needed_docstr(self):
314 | """To be called to count a present non-ignored docstring."""
315 | self.needed += 1
316 | self.found += 1
317 |
318 | def missed_needed_docstring(self):
319 | """To be called to count a missing non-ignored docstring."""
320 | self.needed += 1
321 | self.missing += 1
322 |
323 | def found_empty_file(self):
324 | """To be called to count the presence of an empty file."""
325 | self.is_empty = True
326 |
--------------------------------------------------------------------------------
/docstr_coverage/templates/flat.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | docstr-coverage
16 | docstr-coverage
17 | {{ value }}%
18 | {{ value }}%
19 |
20 |
--------------------------------------------------------------------------------
/docstr_coverage/visitor.py:
--------------------------------------------------------------------------------
1 | """This module handles traversing abstract syntax trees to check for docstrings"""
2 | import re
3 | import tokenize
4 | from ast import (
5 | AsyncFunctionDef,
6 | ClassDef,
7 | FunctionDef,
8 | Module,
9 | NodeVisitor,
10 | get_docstring,
11 | )
12 | from typing import Optional
13 |
14 | ACCEPTED_EXCUSE_PATTERNS = (
15 | re.compile(r"#\s*docstr-coverage\s*:\s*inherit(ed)?\s*"),
16 | re.compile(r"#\s*docstr-coverage\s*:\s*excuse(d)?\s* `.*`\s*"),
17 | )
18 |
19 |
20 | class DocStringCoverageVisitor(NodeVisitor):
21 | """Class to visit nodes, determine whether a node requires a docstring,
22 | and to check for the existence of a docstring"""
23 |
24 | def __init__(self, filename):
25 | self.filename = filename
26 | with open(filename, "rb") as file:
27 | self.tokens = list(tokenize.tokenize(file.readline))
28 | self.symbol_count = 0
29 | self.tree = []
30 |
31 | def visit_Module(self, node: Module):
32 | """Upon visiting a module, initialize :attr:`DocStringCoverageVisitor.tree`
33 | with module-wide node info."""
34 | has_doc = self._has_docstring(node)
35 | is_empty = not len(node.body)
36 | self.tree.append((has_doc, is_empty, None, []))
37 | self.generic_visit(node)
38 |
39 | def visit_ClassDef(self, node: ClassDef):
40 | """Collect information regarding class declaration nodes"""
41 | self._visit_helper(node)
42 |
43 | def visit_FunctionDef(self, node: FunctionDef):
44 | """Collect information regarding function/method declaration nodes"""
45 | self._visit_helper(node)
46 |
47 | def visit_AsyncFunctionDef(self, node: AsyncFunctionDef):
48 | """Collect information regarding async function/method declaration nodes"""
49 | self._visit_helper(node)
50 |
51 | def _visit_helper(self, node):
52 | """Helper method to update :attr:`DocStringCoverageVisitor.tree` with pertinent
53 | documentation information for `node`, then ensure all child nodes are
54 | also visited"""
55 | self.symbol_count += 1
56 | has_doc = self._has_doc_or_excuse(node)
57 | relevant_decorator = self._relevant_decorator(node)
58 | _node = (node.name, has_doc, relevant_decorator, [])
59 | self.tree[-1][-1].append(_node)
60 | self.tree.append(_node)
61 | self.generic_visit(node)
62 | self.tree.pop()
63 |
64 | def _has_doc_or_excuse(self, node):
65 | """Evaluates if the passed node has a corresponding docstring
66 | or if there is an excuse comment"""
67 | return self._has_docstring(node=node) or self._has_excuse(node=node)
68 |
69 | @staticmethod
70 | def _is_excuse_token(token):
71 | """Evaluates whether the given `tokenize.token` represents a valid excuse comment"""
72 | return token.type == tokenize.COMMENT and any(
73 | regex.match(token.string) for regex in ACCEPTED_EXCUSE_PATTERNS
74 | )
75 |
76 | @staticmethod
77 | def _is_skip_token(token):
78 | """Evaluates, for the given tokenize.token,
79 | if said token is expected between a node start and an excuse token"""
80 | return (
81 | token.type == tokenize.NL
82 | or token.type == tokenize.NEWLINE
83 | or (token.type == tokenize.NAME and token.string == "class")
84 | or token.line.strip().startswith("@")
85 | )
86 |
87 | def _has_excuse(self, node):
88 | """Iterates through the tokenize tokens above the passed node to evaluate whether a
89 | doc-missing excuse has been placed (right) above this nodes begin"""
90 | node_start = node.lineno
91 |
92 | # Find the index of first token which starts at the same line as the node
93 | token_index = -1
94 | for i, t in enumerate(self.tokens):
95 | if t.start[0] == node_start:
96 | token_index = i - 1
97 | break
98 |
99 | # Iterate downwards on token index
100 | # (i.e., skip tokens which we expect to see between excuse and node start)
101 | # until we find either an excuse
102 | # or some token which shows that there was no excuse present.
103 | while token_index >= 0:
104 | as_token = self.tokens[token_index]
105 | if self._is_skip_token(as_token):
106 | token_index -= 1
107 | else:
108 | return self._is_excuse_token(as_token)
109 | # Reached the top of the file
110 | return False
111 |
112 | @staticmethod
113 | def _has_docstring(node):
114 | """Uses ast to check if the passed node contains a non-empty docstring"""
115 | return get_docstring(node) is not None and get_docstring(node).strip() != ""
116 |
117 | @staticmethod
118 | def _relevant_decorator(node) -> Optional[str]:
119 | if hasattr(node, "decorator_list"):
120 | for parsed_decorator in node.decorator_list:
121 | if hasattr(parsed_decorator, "id"):
122 | if parsed_decorator.id == "property":
123 | return "@property"
124 | if hasattr(parsed_decorator, "attr"):
125 | if parsed_decorator.attr == "setter":
126 | return "@setter"
127 | if hasattr(parsed_decorator, "attr"):
128 | if parsed_decorator.attr == "deleter":
129 | return "@deleter"
130 | return None
131 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | ##################################################
2 | # Black Code Formatting
3 | ##################################################
4 | [tool.black]
5 | line-length = 100
6 | include = '\.pyi?$'
7 | exclude = '''
8 | /(
9 | \.git
10 | | \.tox
11 | | _build
12 | | build
13 | | dist
14 | | env*
15 | | venv
16 | )/
17 | '''
18 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | exclude = venv, docs/conf.py
3 | max-line-length = 100
4 | extend-ignore = E203, W503, E266
5 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 |
4 | def readme():
5 | with open("README.md") as f:
6 | return f.read()
7 |
8 |
9 | MAJOR, MINOR, MICRO = 2, 3, 2
10 | __VERSION__ = "{}.{}.{}".format(MAJOR, MINOR, MICRO)
11 |
12 | setup(
13 | name="docstr-coverage",
14 | version=__VERSION__,
15 | description=(
16 | "Utility for examining python source files to ensure proper documentation. "
17 | "Lists missing docstrings, and calculates overall docstring coverage "
18 | "percentage rating."
19 | ),
20 | long_description_content_type="text/markdown",
21 | long_description=readme(),
22 | keywords="docstring coverage documentation audit source code statistics report",
23 | url="https://github.com/HunterMcGushion/docstr_coverage",
24 | author="Hunter McGushion",
25 | author_email="hunter@mcgushion.com",
26 | license="MIT",
27 | packages=["docstr_coverage"],
28 | install_requires=[
29 | "click",
30 | "PyYAML",
31 | "tqdm",
32 | "importlib_resources; python_version < '3.9'",
33 | ],
34 | extras_require={
35 | "lint": ["flake8==4.0.1", "black==22.3.0", "isort==5.10.1"],
36 | "test": ["pytest==6.2.5", "pytest-mock==3.4.0"],
37 | },
38 | include_package_data=True,
39 | zip_safe=False,
40 | entry_points=dict(console_scripts=["docstr-coverage=docstr_coverage.cli:execute"]),
41 | classifiers=[
42 | "Programming Language :: Python :: 3",
43 | "Programming Language :: Python :: 3.8",
44 | "Programming Language :: Python :: 3.9",
45 | "Programming Language :: Python :: 3.10",
46 | "Programming Language :: Python :: 3.11",
47 | "Programming Language :: Python :: 3.12",
48 | "License :: OSI Approved :: MIT License",
49 | "Operating System :: OS Independent",
50 | "Topic :: Documentation",
51 | "Topic :: Documentation :: Sphinx",
52 | "Topic :: Software Development",
53 | "Topic :: Software Development :: Documentation",
54 | "Topic :: Software Development :: Libraries :: Python Modules",
55 | "Topic :: Software Development :: Quality Assurance",
56 | "Topic :: Utilities",
57 | ],
58 | )
59 |
--------------------------------------------------------------------------------
/tests/config_files/docstr_ignore.txt:
--------------------------------------------------------------------------------
1 | SomeFile method_to_ignore1 method_to_ignore2 method_to_ignore3
2 | FileWhereWeWantToIgnoreAllSpecialMethods __.+__
3 | .* method_to_ignore_in_all_files
4 | a_very_important_view_file ^get$ ^set$ ^post$
5 | detect_.* get_val.*
--------------------------------------------------------------------------------
/tests/config_files/with_ignore.yml:
--------------------------------------------------------------------------------
1 | fail_under: 90
2 | follow_links: true
3 | ignore_patterns:
4 | .*: method_to_ignore_in_all_files
5 | FileWhereWeWantToIgnoreAllSpecialMethods: "__.+__"
6 | SomeFile:
7 | - method_to_ignore1
8 | - method_to_ignore2
9 | - method_to_ignore3
10 | a_very_important_view_file:
11 | - "^get$"
12 | - "^set$"
13 | - "^post$"
14 | detect_.*:
15 | - "get_val.*"
16 | custom_only_in_yml_specified_filename:
17 | - "method*"
18 | paths:
19 | - tests
20 | - docstr_coverage
21 | percentage_only: true
22 | skip_class_def: true
23 | skip_file_doc: true
24 | skip_init: true
25 | skip_magic: true
26 | skip_private: true
27 | verbose: '2'
28 |
--------------------------------------------------------------------------------
/tests/config_files/without_ignore.yml:
--------------------------------------------------------------------------------
1 | fail_under: 90
2 | follow_links: true
3 | percentage_only: true
4 | skip_class_def: true
5 | skip_file_doc: true
6 | skip_init: true
7 | skip_magic: true
8 | skip_private: true
9 | verbose: '2'
10 |
--------------------------------------------------------------------------------
/tests/excused_samples/fully_excused.py:
--------------------------------------------------------------------------------
1 | """This is a module docstring"""
2 |
3 | import abc
4 |
5 |
6 | # docstr-coverage:excuse `no one is reading this anyways`
7 | class FooBar:
8 |
9 | # docstr-coverage : excuse `I'm super lazy`
10 | def __init__(self):
11 | pass
12 |
13 | # docstr-coverage:excuse `..., I really am...`
14 | @abc.abstractmethod
15 | def function(self):
16 | pass
17 |
18 | # docstr-coverage:excuse `...you can't imagine how much...`
19 | def prop(self):
20 | pass
21 |
22 | # docstr-coverage:excuse `...almost as lazy as async functions...`
23 | async def async_function(self):
24 | pass
25 |
26 |
27 | # docstr-coverage:excuse `... besides: who's checking anyways`
28 | def bar():
29 | pass
30 |
31 |
32 | # docstr-coverage:excuse `... also: setting up async tests suck in general`
33 | async def baz():
34 | pass
35 |
36 |
37 | # docstr-coverage:excuse `no one is reading this anyways`
38 | class FooBarChild(FooBar):
39 |
40 | # docstr-coverage:inherited
41 | def function(self):
42 | pass
43 |
--------------------------------------------------------------------------------
/tests/excused_samples/partially_excused.py:
--------------------------------------------------------------------------------
1 | """This is a module docstring"""
2 |
3 | import abc
4 |
5 |
6 | # docstr-coverage:excuse `no one is reading this anyways`
7 | class FooBar:
8 | def __init__(self):
9 | pass
10 |
11 | # docstr-coverage:excuse `..., I really am...`
12 | @abc.abstractmethod
13 | def function(self):
14 | pass
15 |
16 | # docstr-coverage:excuse `...you can't imagine how much...`
17 | def prop(self):
18 | pass
19 |
20 |
21 | def bar():
22 | pass
23 |
24 |
25 | class FooBarChild(FooBar):
26 | """Wow! A docstring. Crazy"""
27 |
28 | # docstr-coverage:inherited
29 | def function(self):
30 | pass
31 |
--------------------------------------------------------------------------------
/tests/extra_samples/private_undocumented.py:
--------------------------------------------------------------------------------
1 | """Test file for undocumented private methods."""
2 |
3 |
4 | def _foo():
5 | pass
6 |
7 |
8 | def __dunder():
9 | pass
10 |
11 |
12 | async def _afoo():
13 | pass
14 |
15 |
16 | async def __adunder():
17 | pass
18 |
--------------------------------------------------------------------------------
/tests/individual_samples/decorators.py:
--------------------------------------------------------------------------------
1 | """Test file for ignored getters and setters"""
2 |
3 |
4 | class C:
5 | """Example class taken from https://docs.python.org/3/library/functions.html#property."""
6 |
7 | def __init__(self):
8 | """The __init__ is documented."""
9 | self._x = None
10 |
11 | @property
12 | def x(self):
13 | return self._x
14 |
15 | @x.setter
16 | def x(self, value):
17 | self._x = value
18 |
19 | @x.deleter
20 | def x(self):
21 | del self._x
22 |
--------------------------------------------------------------------------------
/tests/individual_samples/long_doc.py:
--------------------------------------------------------------------------------
1 | """
2 | this is a very long docstring
3 |
4 | this is a very long docstring
5 | this is a very long docstring
6 | this is a very long docstring
7 | this is a very long docstring
8 | this is a very long docstring
9 | this is a very long docstring
10 | this is a very long docstring
11 | this is a very long docstring
12 | """
13 |
14 |
15 | class A:
16 | """This is the first class in the alphabeth."""
17 |
18 | # docstr-coverage:excused `test ignore after long docstrings`
19 | def ignored(self):
20 | pass
21 |
22 | def missing(self):
23 | pass
24 |
--------------------------------------------------------------------------------
/tests/sample_files/badges/0.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | docstr-coverage
16 | docstr-coverage
17 | 0%
18 | 0%
19 |
20 |
--------------------------------------------------------------------------------
/tests/sample_files/badges/100.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | docstr-coverage
16 | docstr-coverage
17 | 100%
18 | 100%
19 |
20 |
--------------------------------------------------------------------------------
/tests/sample_files/badges/12.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | docstr-coverage
16 | docstr-coverage
17 | 12%
18 | 12%
19 |
20 |
--------------------------------------------------------------------------------
/tests/sample_files/badges/54.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | docstr-coverage
16 | docstr-coverage
17 | 54%
18 | 54%
19 |
20 |
--------------------------------------------------------------------------------
/tests/sample_files/badges/71.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | docstr-coverage
16 | docstr-coverage
17 | 71%
18 | 71%
19 |
20 |
--------------------------------------------------------------------------------
/tests/sample_files/badges/84.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | docstr-coverage
16 | docstr-coverage
17 | 84%
18 | 84%
19 |
20 |
--------------------------------------------------------------------------------
/tests/sample_files/badges/94.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | docstr-coverage
16 | docstr-coverage
17 | 94%
18 | 94%
19 |
20 |
--------------------------------------------------------------------------------
/tests/sample_files/subdir_a/docstr_ignore.txt:
--------------------------------------------------------------------------------
1 | SomeFile method_to_ignore1 method_to_ignore2 method_to_ignore3
2 | FileWhereWeWantToIgnoreAllSpecialMethods __.+__
3 | .* method_to_ignore_in_all_files
4 | a_very_important_view_file ^get$ ^set$ ^post$
5 | detect_.* get_val.*
--------------------------------------------------------------------------------
/tests/sample_files/subdir_a/documented_file.py:
--------------------------------------------------------------------------------
1 | """This is a module docstring"""
2 |
3 |
4 | class FooBar:
5 | """This is a class-level docstring"""
6 |
7 | def __init__(self):
8 | """This is a dunder method docstring"""
9 |
10 | def function(self):
11 | """This is a regular method docstring"""
12 |
13 | def another_function(self):
14 | """This is a second regular method docstring"""
15 |
16 | async def an_async_function(self):
17 | """This is an async method docstring"""
18 |
19 | @property
20 | def prop(self):
21 | """This is a wrapped method docstring"""
22 |
23 | @prop.setter
24 | def prop(self, value):
25 | # This is skipped by default
26 | pass
27 |
28 |
29 | def foo():
30 | """This is a function docstring"""
31 |
32 | def _foo():
33 | """This is a nested function docstring"""
34 |
35 |
36 | def bar():
37 | """This is another function"""
38 |
39 |
40 | async def baz():
41 | """This is an async function"""
42 |
--------------------------------------------------------------------------------
/tests/sample_files/subdir_a/empty_file.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HunterMcGushion/docstr_coverage/9bd0ea86ca310ee7125f6050ee3ba3ea04fa8801/tests/sample_files/subdir_a/empty_file.py
--------------------------------------------------------------------------------
/tests/sample_files/subdir_a/partly_documented_file.py:
--------------------------------------------------------------------------------
1 | class FooBar:
2 | """This is a class-level docstring"""
3 |
4 | def __init__(self):
5 | pass
6 |
7 |
8 | def foo():
9 | pass
10 |
11 |
12 | def bar():
13 | pass
14 |
--------------------------------------------------------------------------------
/tests/sample_files/subdir_a/some_code_no_docs.py:
--------------------------------------------------------------------------------
1 | def foo():
2 | pass
3 |
--------------------------------------------------------------------------------
/tests/sample_files/subdir_b/docstr_ignore.txt:
--------------------------------------------------------------------------------
1 | SomeFile method_to_ignore1 method_to_ignore2 method_to_ignore3
2 | FileWhereWeWantToIgnoreAllSpecialMethods __.+__
3 | .* method_to_ignore_in_all_files
4 | a_very_important_view_file ^get$ ^set$ ^post$
5 | detect_.* get_val.*
--------------------------------------------------------------------------------
/tests/sample_files/subdir_b/documented_file.py:
--------------------------------------------------------------------------------
1 | """This is a module docstring"""
2 |
3 |
4 | class FooBar:
5 | """This is a class-level docstring"""
6 |
7 | def __init__(self):
8 | """This is a dunder method docstring"""
9 |
10 | def function(self):
11 | """This is a regular method docstring"""
12 |
13 | @property
14 | def prop(self):
15 | """This is a wrapped method docstring"""
16 |
17 | @prop.setter
18 | def prop(self, value):
19 | """This is another wrapped method docstring"""
20 |
21 |
22 | def foo():
23 | """This is a function docstring"""
24 |
25 | def _foo():
26 | """This is a nested function docstring"""
27 |
28 |
29 | def bar():
30 | """This is another function"""
31 |
--------------------------------------------------------------------------------
/tests/sample_files/subdir_b/empty_file.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HunterMcGushion/docstr_coverage/9bd0ea86ca310ee7125f6050ee3ba3ea04fa8801/tests/sample_files/subdir_b/empty_file.py
--------------------------------------------------------------------------------
/tests/sample_files/subdir_b/partly_documented_file.py:
--------------------------------------------------------------------------------
1 | class FooBar:
2 | """This is a class-level docstring"""
3 |
4 | def __init__(self):
5 | pass
6 |
7 |
8 | def foo():
9 | pass
10 |
11 |
12 | def bar():
13 | pass
14 |
--------------------------------------------------------------------------------
/tests/sample_files/subdir_b/some_code_no_docs.py:
--------------------------------------------------------------------------------
1 | def foo():
2 | pass
3 |
--------------------------------------------------------------------------------
/tests/test_badge.py:
--------------------------------------------------------------------------------
1 | """Tests for :mod:`docstr_coverage.badge`"""
2 | import os
3 |
4 | import pytest
5 |
6 | from docstr_coverage.badge import Badge
7 |
8 | CWD = os.path.abspath(os.path.dirname(__file__))
9 | SAMPLE_BADGE_DIR = os.path.join(CWD, "sample_files", "badges")
10 |
11 |
12 | def _clear_whitespace(string):
13 | return "".join(string.split())
14 |
15 |
16 | @pytest.mark.parametrize(
17 | ["given_path", "expected"], [("foo/bar.svg", "foo/bar.svg"), ("foo", "foo.svg")]
18 | )
19 | def test_badge_path(given_path, expected):
20 | """Test that :attr:`docstr_coverage.badge.Badge.path` is set correctly when given paths to both
21 | files and directories"""
22 | b = Badge(given_path, 100)
23 | assert b.path == expected
24 |
25 |
26 | @pytest.mark.parametrize(
27 | ["coverage", "expected"],
28 | [
29 | (100, "#4c1"),
30 | (97.5, "#4c1"),
31 | (95, "#4c1"),
32 | (91.9, "#97CA00"),
33 | (90, "#97CA00"),
34 | (83.6, "#a4a61d"),
35 | (75, "#a4a61d"),
36 | (74.9, "#a4a61d"),
37 | (74.3, "#dfb317"),
38 | (62.4, "#dfb317"),
39 | (60, "#dfb317"),
40 | (51.1, "#fe7d37"),
41 | (40, "#fe7d37"),
42 | (1.3, "#e05d44"),
43 | (0, "#e05d44"),
44 | (-32.32, "#9f9f9f"),
45 | ],
46 | )
47 | def test_badge_color(coverage, expected):
48 | """Test that :attr:`docstr_coverage.badge.Badge.color` is correct according to `coverage`"""
49 | b = Badge(".", coverage)
50 | assert b.color == expected
51 |
52 |
53 | @pytest.mark.parametrize(
54 | ["coverage", "expected_filename"],
55 | [
56 | (100, "100.svg"),
57 | (94.2, "94.svg"),
58 | (83.5, "84.svg"),
59 | (70.9, "71.svg"),
60 | (54.3, "54.svg"),
61 | (12, "12.svg"),
62 | (0.1, "0.svg"),
63 | ],
64 | )
65 | def test_badge_contents(coverage, expected_filename):
66 | """Test that stringified badge (:attr:`docstr_coverage.badge.Badge.badge`) is correct before
67 | saving SVG file"""
68 | b = Badge(".", coverage)
69 | with open(os.path.join(SAMPLE_BADGE_DIR, expected_filename), "r") as f:
70 | assert _clear_whitespace(b.badge) == _clear_whitespace(f.read())
71 |
72 |
73 | @pytest.mark.parametrize(["given_path", "coverage"], [("foo/bar.svg", 90)])
74 | def test_badge_save(given_path, coverage, mocker):
75 | """Test that :meth:`docstr_coverage.badge.Badge.save` opens the expected filepath and writes the
76 | correct badge contents to the expected location"""
77 | mock_open = mocker.patch("docstr_coverage.badge.open", mocker.mock_open())
78 |
79 | b = Badge(given_path, coverage)
80 | b.save()
81 | mock_open.assert_called_once_with(b.path, "w")
82 | mock_open().write.assert_called_once_with(b.badge)
83 |
--------------------------------------------------------------------------------
/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | """Tests for :mod:`docstr_coverage.cli`"""
2 | import os
3 | import re
4 | import sys
5 | from typing import List, Optional
6 |
7 | import pytest
8 | from click.testing import CliRunner
9 |
10 | from docstr_coverage.cli import (
11 | collect_filepaths,
12 | do_include_filepath,
13 | execute,
14 | parse_ignore_names_file,
15 | parse_ignore_patterns_from_dict,
16 | )
17 |
18 |
19 | class Samples:
20 | def __init__(self, dirpath: str):
21 | """Convenience/helper class to organize paths to sample files
22 |
23 | Parameters
24 | ----------
25 | dirpath: String
26 | Path to a sample file subdirectory containing the required sample scripts"""
27 | self.dirpath = dirpath
28 | self.documented = os.path.join(dirpath, "documented_file.py")
29 | self.empty = os.path.join(dirpath, "empty_file.py")
30 | self.partial = os.path.join(dirpath, "partly_documented_file.py")
31 | self.undocumented = os.path.join(dirpath, "some_code_no_docs.py")
32 |
33 | @property
34 | def all(self) -> List[str]:
35 | """Get all of the sample script paths inside the subdirectory"""
36 | return [self.documented, self.empty, self.partial, self.undocumented]
37 |
38 |
39 | CWD = os.path.abspath(os.path.dirname(__file__))
40 | SAMPLES_DIR = os.path.abspath(os.path.join(CWD, "sample_files"))
41 | SAMPLES_A = Samples(os.path.join(SAMPLES_DIR, "subdir_a"))
42 | SAMPLES_B = Samples(os.path.join(SAMPLES_DIR, "subdir_b"))
43 |
44 |
45 | @pytest.fixture
46 | def runner() -> CliRunner:
47 | """Click CliRunner fixture"""
48 | runner = CliRunner()
49 | return runner
50 |
51 |
52 | @pytest.fixture(autouse=False, scope="function")
53 | def cd_tests_dir_fixture():
54 | """Fixture to change current working directory to "docstr_coverage/tests" for the test's
55 | duration before returning to the original current working directory"""
56 | original_cwd = os.getcwd()
57 | os.chdir(CWD)
58 | yield
59 | os.chdir(original_cwd)
60 |
61 |
62 | @pytest.fixture
63 | def exclude_re(request) -> "re.Pattern":
64 | """Indirectly parametrized fixture that expects a string or None as input"""
65 | pattern = getattr(request, "param", None)
66 | return re.compile(r"{}".format(pattern)) if pattern else None
67 |
68 |
69 | @pytest.mark.parametrize(
70 | ["filepath", "exclude_re", "expected"],
71 | [
72 | ("foo.js", None, False),
73 | ("foo.txt", None, False),
74 | ("foobar", None, False),
75 | ("foo_py.js", None, False),
76 | ("foo.py", None, True),
77 | ("foo/bar.py", None, True),
78 | ("foo.py", "bar", True),
79 | ("foo.py", "fo", False),
80 | ("foo.py", "foo.+\\.py", True), # `exclude_re` requires something between "foo" and ".py"
81 | ("foo.py", "foo.+", False), # ".+" applied to extension, so `filepath` is excluded
82 | ("foo_bar.py", "foo.+", False),
83 | ("foo_bar.py", "foo.+\\.py", False),
84 | ("foo/bar.py", "foo", False),
85 | ("foo/bar.py", "bar", True),
86 | ("foo/bar.py", ".*bar", False),
87 | ("foo/bar.py", "bar/", True),
88 | ("foo/bar/baz.py", "bar/.*", True), # `exclude_re` starts with "bar"
89 | ("foo/bar/baz.py", ".*/bar/.*", False),
90 | ],
91 | indirect=["exclude_re"],
92 | )
93 | def test_do_include_filepath(filepath: str, exclude_re: Optional[str], expected: bool):
94 | """Test that :func:`docstr_coverage.cli.do_include_filepath` includes correct filepaths
95 |
96 | Parameters
97 | ----------
98 | filepath: String
99 | Filepath to match with `exclude_re`
100 | exclude_re: String, or None
101 | Pattern to check against `filepath`. Indirectly parametrized to be `re.Pattern` or None
102 | expected: Boolean
103 | Expected response to whether `filepath` should be included"""
104 | actual = do_include_filepath(filepath, exclude_re)
105 | assert actual is expected
106 |
107 |
108 | @pytest.mark.parametrize(
109 | ["paths", "exclude", "expected"],
110 | [
111 | ([SAMPLES_DIR], "", SAMPLES_A.all + SAMPLES_B.all),
112 | ([SAMPLES_A.documented], "", [SAMPLES_A.documented]),
113 | ([SAMPLES_A.dirpath], "", SAMPLES_A.all),
114 | ([SAMPLES_A.dirpath], ".*/sample_files/.*", []),
115 | ([SAMPLES_A.dirpath], ".*documented_file.*", [SAMPLES_A.empty, SAMPLES_A.undocumented]),
116 | ([SAMPLES_A.empty, SAMPLES_A.documented], "", [SAMPLES_A.documented, SAMPLES_A.empty]),
117 | ([SAMPLES_A.dirpath, SAMPLES_B.dirpath], "", SAMPLES_A.all + SAMPLES_B.all),
118 | ([SAMPLES_A.dirpath, SAMPLES_B.dirpath], ".*subdir_a.*", SAMPLES_B.all),
119 | (
120 | [SAMPLES_A.dirpath, SAMPLES_B.documented, SAMPLES_B.empty],
121 | ".*subdir_a.*",
122 | [SAMPLES_B.documented, SAMPLES_B.empty],
123 | ),
124 | (
125 | [SAMPLES_A.dirpath, SAMPLES_B.dirpath],
126 | ".*_file\\.py",
127 | [SAMPLES_A.undocumented, SAMPLES_B.undocumented],
128 | ),
129 | ],
130 | )
131 | def test_collect_filepaths(paths: List[str], exclude: str, expected: List[str]):
132 | """Test that :func:`docstr_coverage.cli.collect_filepaths` includes correct filepaths
133 |
134 | Parameters
135 | ----------
136 | paths: List
137 | Path(s) to directory/file
138 | exclude: String
139 | Pattern for filepaths to exclude
140 | expected: List
141 | Expected list of filepaths to include in search"""
142 | actual = collect_filepaths(*paths, follow_links=False, exclude=exclude)
143 | assert actual == expected
144 |
145 |
146 | # we could manually implement order-ignoring ==,
147 | # but I do not think its worth it, since py 3.6+ supports
148 | # it and thus runs the test
149 | @pytest.mark.skipif(
150 | sys.version_info < (3, 6),
151 | reason="order-ignoring dict == comparison requires python3.6 or later ",
152 | )
153 | def test_ignore_patterns():
154 | """Test that parsing an ignore_pattern_dict (typically coming from yaml) leads
155 | to the expected list-of-string tuples"""
156 | dict_patterns = {
157 | "SomeFile": ["method_to_ignore1", "method_to_ignore2", "method_to_ignore3"],
158 | "FileWhereWeWantToIgnoreAllSpecialMethods": "__.+__",
159 | ".*": "method_to_ignore_in_all_files",
160 | "a_very_important_view_file": ["^get$", "^set$", "^post$"],
161 | "detect_.*": ["get_val.*"],
162 | }
163 | expected = (
164 | ["SomeFile", "method_to_ignore1", "method_to_ignore2", "method_to_ignore3"],
165 | ["FileWhereWeWantToIgnoreAllSpecialMethods", "__.+__"],
166 | [".*", "method_to_ignore_in_all_files"],
167 | ["a_very_important_view_file", "^get$", "^set$", "^post$"],
168 | ["detect_.*", "get_val.*"],
169 | )
170 | actual = parse_ignore_patterns_from_dict(dict_patterns)
171 | assert actual == expected
172 |
173 |
174 | @pytest.mark.parametrize(
175 | ["input_dict", "error"],
176 | [
177 | ("not_a_dict", TypeError), # Wrong type: not a dict
178 | ({0: ["get_val.*"]}, TypeError), # Wrong type: non-string key
179 | ({"SomeFile": 0}, TypeError), # Wrong type: non string non List[str]
180 | ({"SomeFile": [0]}, TypeError), # Wrong type: non string non List[str]
181 | ({"SomeFile": {"asd", "adw"}}, TypeError), # Wrong type: non string non List[str]
182 | ({" ": ["get_val.*"]}, ValueError), # Empty string not permitted
183 | ({"SomeFile": ""}, ValueError), # Empty string not permitted
184 | ({"SomeFile": " "}, ValueError), # Empty string not permitted
185 | ],
186 | )
187 | def test_ignore_patterns_from_dict_errors(input_dict, error):
188 | """Test that invalid yaml ignore_pattern dicts raises an error
189 |
190 | Parameters
191 | ----------
192 | input_dict
193 | The faulty input
194 | error: Union[TypeError, ValueError]
195 | The expected error"""
196 | with pytest.raises(error):
197 | parse_ignore_patterns_from_dict(input_dict)
198 |
199 |
200 | @pytest.mark.parametrize(
201 | ["path", "expected"],
202 | [
203 | ("", ()),
204 | ("this_file_does_not_exist.txt", ()),
205 | (
206 | os.path.join(SAMPLES_A.dirpath, "docstr_ignore.txt"),
207 | (
208 | ["SomeFile", "method_to_ignore1", "method_to_ignore2", "method_to_ignore3"],
209 | ["FileWhereWeWantToIgnoreAllSpecialMethods", "__.+__"],
210 | [".*", "method_to_ignore_in_all_files"],
211 | ["a_very_important_view_file", "^get$", "^set$", "^post$"],
212 | ["detect_.*", "get_val.*"],
213 | ),
214 | ),
215 | ],
216 | )
217 | def test_parse_ignore_names_file(path: str, expected: tuple):
218 | """Test that :func:`docstr_coverage.cli.parse_ignore_names_file` correctly parses patterns
219 |
220 | Parameters
221 | ----------
222 | path: String
223 | Path to a file containing patterns to ignore
224 | expected: Tuple
225 | Expected parsed patterns from `path`"""
226 | actual = parse_ignore_names_file(path)
227 | assert actual == expected
228 |
229 |
230 | @pytest.mark.parametrize(
231 | ["paths", "expected_output"],
232 | [
233 | [[SAMPLES_A.dirpath], "66.66666666666667"],
234 | [[SAMPLES_A.partial], "20.0"],
235 | [[SAMPLES_A.documented], "100.0"],
236 | [[SAMPLES_A.undocumented], "0.0"],
237 | [[SAMPLES_A.undocumented, SAMPLES_A.documented], "84.61538461538461"],
238 | ],
239 | )
240 | @pytest.mark.parametrize("verbose_flag", [["-v", "0"], ["-v", "1"], ["-v", "2"], ["-v", "3"]])
241 | def test_percentage_only(
242 | paths: List[str], expected_output: str, verbose_flag: List[str], runner: CliRunner
243 | ):
244 | """Test that using the `--percentage-only` CLI option works correctly
245 |
246 | Parameters
247 | ----------
248 | paths: List[str]
249 | Path arguments provided to CLI
250 | expected_output: String
251 | Expected stdout output of invoking the CLI command
252 | verbose_flag: List[str]
253 | Verbosity option with which to execute the command. `--percentage-only` should function the
254 | same regardless of verbosity, so this should basically be ignored
255 | runner: CliRunner
256 | Click utility to invoke command line scripts"""
257 | actual_output = runner.invoke(execute, ["--percentage-only"] + verbose_flag + paths)
258 | assert actual_output.stdout == "{}\n".format(expected_output) # `print`'s default `end`="\n"
259 |
260 |
261 | ##################################################
262 | # Click Tests
263 | ##################################################
264 | @pytest.mark.parametrize(
265 | "paths",
266 | [
267 | pytest.param([SAMPLES_DIR], id="samples_dir_x1"),
268 | pytest.param([SAMPLES_A.documented], id="files_x1"),
269 | pytest.param([SAMPLES_A.empty, SAMPLES_A.partial], id="files_x2"),
270 | pytest.param([SAMPLES_A.dirpath, SAMPLES_B.dirpath], id="dirs_x2"),
271 | pytest.param([SAMPLES_A.empty, SAMPLES_A.partial, SAMPLES_B.dirpath], id="files_x2+dir_x1"),
272 | pytest.param([os.path.join("sample_files", "subdir_a")], id="rel_dir_x1"),
273 | ],
274 | )
275 | @pytest.mark.parametrize(
276 | ["follow_links_flag", "follow_links_value"],
277 | [
278 | pytest.param([], False, id="no_follow_links"),
279 | pytest.param(["-l"], True, id="short_follow_links"),
280 | pytest.param(["--followlinks"], True, id="long_follow_links"),
281 | ],
282 | )
283 | @pytest.mark.parametrize(
284 | ["exclude_flag", "exclude_value"],
285 | [
286 | pytest.param([], None, id="no_exclude"),
287 | pytest.param(["-e", ".*"], ".*", id="short_exclude_x1"),
288 | pytest.param(["--exclude", "foo"], "foo", id="long_exclude_x1"),
289 | pytest.param(["--exclude", "foo"], "foo", id="long_exclude_x1_2"),
290 | # TODO: Add cases with multiple short and long patterns, and combinations of short/long
291 | ],
292 | )
293 | @pytest.mark.usefixtures("cd_tests_dir_fixture")
294 | def test_cli_collect_filepaths(
295 | paths: List[str],
296 | follow_links_flag: List[str],
297 | follow_links_value: bool,
298 | exclude_flag: List[str],
299 | exclude_value: Optional[str],
300 | runner: CliRunner,
301 | mocker,
302 | ):
303 | """Test that CLI inputs are correctly interpreted and passed along to
304 | :func:`docstr_coverage.cli.collect_filepaths`
305 |
306 | Parameters
307 | ----------
308 | paths: List[str]
309 | Path arguments provided to CLI. These should be made absolute before they are passed to
310 | :func:`docstr_coverage.cli.collect_filepaths`
311 | follow_links_flag: List[str]
312 | CLI option input for whether symbolic links should be followed
313 | follow_links_value: Boolean
314 | Processed value of `follow_links_flag` expected by function call
315 | exclude_flag: List[str]
316 | CLI option input for paths to exclude from search
317 | exclude_value: String, or None
318 | Processed value of `exclude_flag` expected by function call
319 | runner: CliRunner
320 | Click utility to invoke command line scripts
321 | mocker: pytest_mock.MockFixture
322 | Mock to check arguments passed to :func:`docstr_coverage.cli.collect_filepaths`"""
323 | mock_collect_filepaths = mocker.patch("docstr_coverage.cli.collect_filepaths")
324 |
325 | runner.invoke(execute, follow_links_flag + exclude_flag + paths)
326 |
327 | mock_collect_filepaths.assert_called_once_with(
328 | *[os.path.abspath(_) for _ in paths], follow_links=follow_links_value, exclude=exclude_value
329 | )
330 |
331 |
332 | @pytest.mark.parametrize(
333 | "paths",
334 | [
335 | pytest.param([SAMPLES_DIR], id="samples_dir_x1"),
336 | pytest.param([SAMPLES_A.documented], id="files_x1"),
337 | pytest.param([SAMPLES_A.empty, SAMPLES_A.partial], id="files_x2"),
338 | pytest.param([SAMPLES_A.dirpath, SAMPLES_B.dirpath], id="dirs_x2"),
339 | pytest.param([SAMPLES_A.empty, SAMPLES_A.partial, SAMPLES_B.dirpath], id="files_x2+dir_x1"),
340 | pytest.param([os.path.join("sample_files", "subdir_a")], id="rel_dir_x1"),
341 | ],
342 | )
343 | @pytest.mark.parametrize(
344 | ["config_flag", "use_yml_ignore"],
345 | [
346 | pytest.param([], False, id="no_config_specified"),
347 | pytest.param(
348 | ["-C", os.path.join("config_files", "with_ignore.yml")],
349 | True,
350 | id="short_config_specifier_w_ignore",
351 | ),
352 | pytest.param(
353 | ["--config", os.path.join("config_files", "with_ignore.yml")],
354 | True,
355 | id="long_config_specifier_w_ignore",
356 | ),
357 | pytest.param(
358 | ["-C", os.path.join("config_files", "without_ignore.yml")],
359 | False,
360 | id="short_config_specifier_wo_ignore",
361 | ),
362 | pytest.param(
363 | ["--config", os.path.join("config_files", "without_ignore.yml")],
364 | False,
365 | id="long_config_specifier_wo_ignore",
366 | ),
367 | ],
368 | )
369 | @pytest.mark.parametrize(
370 | ["ignore_file_flag", "use_ignore_file"],
371 | [
372 | pytest.param([], False, id="no_ignore_file"),
373 | pytest.param(
374 | ["-d", os.path.join("config_files", "docstr_ignore.txt")], True, id="short_ignore_file"
375 | ),
376 | pytest.param(
377 | ["--docstr-ignore-file", os.path.join("config_files", "docstr_ignore.txt")],
378 | True,
379 | id="long_ignore_file",
380 | ),
381 | ],
382 | )
383 | @pytest.mark.usefixtures("cd_tests_dir_fixture")
384 | @pytest.mark.skipif(
385 | sys.version_info < (3, 6), reason="assert_called_once requires python3.6 or later "
386 | )
387 | def test_ignore_patterns_files(
388 | paths: List[str],
389 | config_flag: List[str],
390 | use_yml_ignore: bool,
391 | ignore_file_flag: List[str],
392 | use_ignore_file: bool,
393 | runner: CliRunner,
394 | mocker,
395 | ):
396 | """Test that CLI inputs are correctly interpreted and passed along to
397 | :func:`docstr_coverage.cli.collect_filepaths`
398 |
399 | Parameters
400 | ----------
401 | paths: List[str]
402 | Path arguments provided to CLI. These should be made absolute before they are passed to
403 | :func:`docstr_coverage.cli.collect_filepaths`
404 | config_flag: List[str]
405 | CLI option to specify path of yml config file
406 | use_yml_ignore: Boolean
407 | True iff `config_flag` points to a file with custom ignore patterns
408 | ignore_file_flag: List[str]
409 | CLI option to specify path of a plain ignore patterns file
410 | use_ignore_file: Boolean
411 | True iff `ignore_file_flag` points to a file with custom ignore patterns
412 | runner: CliRunner
413 | Click utility to invoke command line scripts
414 | mocker: pytest_mock.MockFixture
415 | Mock to check arguments passed to :func:`docstr_coverage.cli.collect_filepaths`"""
416 |
417 | # Check that there is no `.docstr_coverage` file added to the test folder,
418 | # which may be used as default
419 | assert not os.path.isfile(".docstr_coverage") and not os.path.isfile(
420 | ".docstr.yaml"
421 | ), "This test must run in a folder without config or ignore files"
422 |
423 | mock_parse_ig_f = mocker.patch("docstr_coverage.cli.parse_ignore_names_file")
424 | parse_ig_from_dict = mocker.patch("docstr_coverage.cli.parse_ignore_patterns_from_dict")
425 |
426 | run_result = runner.invoke(execute, paths + config_flag + ignore_file_flag)
427 |
428 | if use_yml_ignore and use_ignore_file:
429 | assert (
430 | run_result.exception
431 | ), "No exception was raised even though yml and txt custom ignore patterns were passed"
432 | assert isinstance(run_result.exception, ValueError)
433 | assert (
434 | "Ignore patterns must be specified in only one location at a time."
435 | in run_result.exception.args[0]
436 | )
437 |
438 | elif use_yml_ignore:
439 | mock_parse_ig_f.assert_not_called()
440 | parse_ig_from_dict.assert_called_once()
441 | elif use_ignore_file:
442 | parse_ig_from_dict.assert_not_called()
443 | mock_parse_ig_f.assert_called_once()
444 | else:
445 | parse_ig_from_dict.assert_not_called()
446 | mock_parse_ig_f.assert_not_called()
447 |
448 |
449 | @pytest.mark.parametrize(
450 | ["paths", "path_contains_py"],
451 | [
452 | pytest.param([SAMPLES_DIR], True, id="samples_dir_x1"),
453 | pytest.param([SAMPLES_A.documented], True, id="files_x1"),
454 | pytest.param([SAMPLES_A.empty, SAMPLES_A.partial], True, id="files_x2"),
455 | pytest.param([SAMPLES_A.dirpath, SAMPLES_B.dirpath], True, id="dirs_x2"),
456 | pytest.param(
457 | [SAMPLES_A.empty, SAMPLES_A.partial, SAMPLES_B.dirpath], True, id="files_x2+dir_x1"
458 | ),
459 | pytest.param([os.path.join("sample_files", "subdir_a")], True, id="rel_dir_x1"),
460 | pytest.param(
461 | [os.path.join("config_files", "docstr_ignore.txt")], False, id="file_with_no_python"
462 | ),
463 | pytest.param([os.path.join(CWD, "config_files")], False, id="folder_with_no_python"),
464 | ],
465 | )
466 | @pytest.mark.parametrize(
467 | ["accept_empty_flag", "accept_empty_value"],
468 | [
469 | pytest.param([], False, id="no_accept_empty"),
470 | pytest.param(["-a"], True, id="short_accept_empty"),
471 | pytest.param(["--accept-empty"], True, id="long_accept_empty"),
472 | ],
473 | )
474 | @pytest.mark.usefixtures("cd_tests_dir_fixture")
475 | def test_accept_empty(
476 | paths: List[str],
477 | path_contains_py: bool,
478 | accept_empty_flag: List[str],
479 | accept_empty_value: bool,
480 | runner: CliRunner,
481 | ):
482 | """Test that the `--accept-empty`/`-a` flag leads to the correct exit codes
483 |
484 | Parameters
485 | ----------
486 | paths: List[str]
487 | Path arguments provided to CLI. These should be made absolute before they are passed to
488 | :func:`docstr_coverage.cli.collect_filepaths`
489 | path_contains_py: bool
490 | True iff the passed paths point (directly or indirectly via dir) to at least one .py file
491 | accept_empty_flag: List[str]
492 | Flag under test
493 | accept_empty_value: bool
494 | True iff the flag under test specifies to return exit code 0 if no .py file was found
495 | runner: CliRunner
496 | Click utility to invoke command line scripts"""
497 |
498 | dont_fail_due_to_coverage = ["--fail-under=5"]
499 |
500 | run_result = runner.invoke(execute, paths + accept_empty_flag + dont_fail_due_to_coverage)
501 |
502 | if accept_empty_flag or path_contains_py:
503 | assert run_result.exit_code == 0
504 | else:
505 | assert run_result.exit_code == 1
506 |
507 |
508 | @pytest.mark.parametrize(
509 | ["flags", "expected_coverage"],
510 | [
511 | pytest.param([], 75.0, id="default_property_config"),
512 | pytest.param(["--skip-property"], 100.0, id="skip-property"),
513 | pytest.param(["-sp"], 100.0, id="skip-property (short: -sp)"),
514 | pytest.param(["--include-setter"], 60.0, id="include-setter"),
515 | pytest.param(["-is"], 60.0, id="include-setter (short: -is)"),
516 | pytest.param(["--include-deleter"], 60.0, id="include-deleter"),
517 | pytest.param(["-idel"], 60.0, id="include-deleter (short: -idel)"),
518 | ],
519 | )
520 | @pytest.mark.usefixtures("cd_tests_dir_fixture")
521 | def test_property_flags(
522 | flags: List[str],
523 | expected_coverage: float,
524 | runner: CliRunner,
525 | ):
526 | """Test that the CLI flags for properties work correctly"""
527 | paths = [os.path.join("individual_samples", "decorators.py")]
528 | flags.append("--percentage-only")
529 | run_result = runner.invoke(execute, paths + flags)
530 | assert float(run_result.stdout) == expected_coverage
531 |
532 |
533 | ##################################################
534 | # Deprecation Tests
535 | ##################################################
536 | @pytest.mark.parametrize(["paths"], [pytest.param([SAMPLES_DIR])])
537 | @pytest.mark.parametrize(
538 | ["deprecated_option"],
539 | [
540 | pytest.param(["--failunder=60"]),
541 | pytest.param(["--followlinks"]),
542 | pytest.param(["--skipclassdef"]),
543 | pytest.param(["--skipfiledoc"]),
544 | pytest.param(["--skipinit"]),
545 | pytest.param(["--skipmagic"]),
546 | ],
547 | )
548 | def test_deprecations(paths, deprecated_option, runner: CliRunner):
549 | """Test that using deprecated CLI options logs a warning
550 |
551 | Parameters
552 | ----------
553 | paths: List[str]
554 | Path arguments provided to CLI. These should be made absolute before they are passed to
555 | :func:`docstr_coverage.cli.collect_filepaths`
556 | deprecated_option: List[str]
557 | CLI option that has been deprecated
558 | runner: CliRunner
559 | Click utility to invoke command line scripts"""
560 | run_result = runner.invoke(execute, deprecated_option + paths)
561 | assert run_result.stdout.startswith(
562 | "Using deprecated {}".format(deprecated_option[0].split("=")[0])
563 | )
564 |
565 |
566 | @pytest.mark.parametrize(
567 | ["help_flag"],
568 | [pytest.param(["--help"], id="long: --help"), pytest.param(["-h"], id="short: -h")],
569 | )
570 | def test_help_smoke(help_flag: str, runner: CliRunner):
571 | """Smoke test which ensures that help is printed with exit code 0
572 |
573 | Parameters
574 | ----------
575 | help_flag:
576 | The (short or long) help path argument
577 | runner: CliRunner
578 | Click utility to invoke command line scripts"""
579 | run_result = runner.invoke(execute, help_flag)
580 | assert run_result.exit_code == 0
581 | assert isinstance(run_result.stdout, str)
582 | assert "Measure docstring coverage for" in run_result.stdout
583 |
--------------------------------------------------------------------------------
/tests/test_config_file.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import click
4 | import pytest
5 | import yaml
6 |
7 | from docstr_coverage.config_file import set_config_defaults
8 |
9 | TEST_CONFIG_FILE = ".docstr.yaml"
10 |
11 | fake_config_file = {
12 | "paths": ["tests", "docstr_coverage"],
13 | "verbose": "2",
14 | "skip_magic": True,
15 | "skip_file_doc": True,
16 | "skip_init": True,
17 | "skip_class_def": True,
18 | "skip_private": True,
19 | "follow_links": True,
20 | "fail_under": 90,
21 | "percentage_only": True,
22 | }
23 | fake_config_file_string_paths = {
24 | "paths": "docstr_coverage",
25 | "verbose": "2",
26 | "skip_magic": True,
27 | "skip_file_doc": True,
28 | "skip_init": True,
29 | "skip_class_def": True,
30 | "skip_private": True,
31 | "follow_links": True,
32 | "fail_under": 90,
33 | "percentage_only": True,
34 | }
35 | fake_config_file_ignore_patterns = fake_config_file.copy()
36 | test_ignore_patterns = {
37 | "SomeFile": ["method_to_ignore1", "method_to_ignore2", "method_to_ignore3"],
38 | "FileWhereWeWantToIgnoreAllSpecialMethods": "__.+__",
39 | ".*": "method_to_ignore_in_all_files",
40 | "a_very_important_view_file": ["^get$", "^set$", "^post$"],
41 | "detect_.*": ["get_val.*"],
42 | }
43 | fake_config_file_ignore_patterns["ignore_patterns"] = test_ignore_patterns
44 |
45 |
46 | @pytest.fixture(autouse=True)
47 | def run_before_and_after_tests(tmpdir):
48 | """Delete the fake config file before and after every test
49 | to make sure the tests do *NOT* influence each other"""
50 | if os.path.isfile(TEST_CONFIG_FILE):
51 | os.remove(TEST_CONFIG_FILE)
52 | yield # this is where the testing happens
53 | if os.path.isfile(TEST_CONFIG_FILE):
54 | os.remove(TEST_CONFIG_FILE)
55 |
56 |
57 | def test_set_config_defaults_no_conf_file():
58 | """Test set_config_defaults when .docstr.yaml is missing"""
59 | ctx = click.Context(click.Command("paths"))
60 | ctx.params = {"paths": ()}
61 | value = set_config_defaults(ctx, click.Option("-C", "--config"), None)
62 | assert value is None
63 | assert ctx.params == {"paths": ()}
64 |
65 |
66 | def test_set_config_defaults():
67 | """Test ReadConfigFile with fake .docstr.yaml with multiple paths"""
68 | # fake .docstr.yaml
69 | with open(TEST_CONFIG_FILE, "w") as outfile:
70 | yaml.dump(fake_config_file, outfile, default_flow_style=False)
71 |
72 | ctx = click.Context(click.Command("paths"))
73 | ctx.params = {"paths": ()}
74 | value = set_config_defaults(ctx, click.Option("-C", "--config"), ".docstr.yaml")
75 | outfile.close()
76 | # delete paths key
77 | del fake_config_file["paths"]
78 |
79 | assert ".docstr.yaml" in value
80 | assert type(ctx.params["paths"]) == tuple
81 | assert "test" in ctx.params["paths"][0]
82 | assert "docstr_coverage" in ctx.params["paths"][1]
83 | assert ctx.default_map.keys() == fake_config_file.keys()
84 | assert ctx.default_map["verbose"] == fake_config_file["verbose"]
85 |
86 |
87 | def test_set_config_defaults_with_string_paths():
88 | """Test ReadConfigFile with fake .docstr.yaml with only one path"""
89 | # fake .docstr.yaml
90 | with open(TEST_CONFIG_FILE, "w") as outfile:
91 | yaml.dump(fake_config_file_string_paths, outfile, default_flow_style=False)
92 |
93 | ctx = click.Context(click.Command("paths"))
94 | ctx.params = {"paths": ()}
95 | value = set_config_defaults(ctx, click.Option("-C", "--config"), ".docstr.yaml")
96 | outfile.close()
97 |
98 | # delete paths key
99 | del fake_config_file_string_paths["paths"]
100 |
101 | assert ".docstr.yaml" in value
102 | assert type(ctx.params["paths"]) == tuple
103 | assert "docstr_coverage" in ctx.params["paths"][0]
104 | assert ctx.default_map.keys() == fake_config_file_string_paths.keys()
105 | assert ctx.default_map["verbose"] == fake_config_file_string_paths["verbose"]
106 |
107 |
108 | def test_set_config_defaults_with_ignore_patterns():
109 | """Test ReadConfigFile with fake .docstr.yaml w.r.t. the parsing of custom ignore
110 | patterns"""
111 | # fake .docstr.yaml
112 | with open(TEST_CONFIG_FILE, "w") as outfile:
113 | yaml.dump(fake_config_file_ignore_patterns, outfile, default_flow_style=False)
114 |
115 | ctx = click.Context(click.Command("paths"))
116 | ctx.params = {"paths": ()}
117 | value = set_config_defaults(ctx, click.Option("-C", "--config"), ".docstr.yaml")
118 | outfile.close()
119 |
120 | # delete paths key
121 | del fake_config_file_ignore_patterns["paths"]
122 | del fake_config_file_ignore_patterns["ignore_patterns"]
123 |
124 | assert ".docstr.yaml" in value
125 | assert type(ctx.params["paths"]) == tuple
126 | assert "docstr_coverage" in ctx.params["paths"][0]
127 | assert ctx.default_map.keys() == fake_config_file_ignore_patterns.keys()
128 | assert ctx.default_map["verbose"] == fake_config_file_ignore_patterns["verbose"]
129 | assert ctx.params["ignore_patterns"] == test_ignore_patterns
130 |
--------------------------------------------------------------------------------
/tests/test_coverage.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import platform
4 |
5 | import pytest
6 |
7 | from docstr_coverage import analyze
8 | from docstr_coverage.ignore_config import IgnoreConfig
9 | from docstr_coverage.printers import _GRADES, LegacyPrinter, MarkdownPrinter
10 |
11 | SAMPLES_DIRECTORY = os.path.join("tests", "sample_files", "subdir_a")
12 | EMPTY_FILE_PATH = os.path.join(SAMPLES_DIRECTORY, "empty_file.py")
13 | DOCUMENTED_FILE_PATH = os.path.join(SAMPLES_DIRECTORY, "documented_file.py")
14 | PARTLY_DOCUMENTED_FILE_PATH = os.path.join(SAMPLES_DIRECTORY, "partly_documented_file.py")
15 | SOME_CODE_NO_DOCS_FILE_PATH = os.path.join(SAMPLES_DIRECTORY, "some_code_no_docs.py")
16 |
17 | EXCUSED_SAMPLES_DIRECTORY = os.path.join("tests", "excused_samples")
18 | FULLY_EXCUSED_FILE_PATH = os.path.join(EXCUSED_SAMPLES_DIRECTORY, "fully_excused.py")
19 | PARTLY_EXCUSED_FILE_PATH = os.path.join(EXCUSED_SAMPLES_DIRECTORY, "partially_excused.py")
20 |
21 | SAMPLES_C_DIRECTORY = os.path.join("tests", "extra_samples")
22 | PRIVATE_NO_DOCS_PATH = os.path.join(SAMPLES_C_DIRECTORY, "private_undocumented.py")
23 |
24 | INDIVIDUAL_SAMPLES_DIR = os.path.join("tests", "individual_samples")
25 |
26 |
27 | def test_should_report_for_an_empty_file():
28 | result = analyze([EMPTY_FILE_PATH])
29 | file_results, total_results = result.to_legacy()
30 | assert file_results == {
31 | EMPTY_FILE_PATH: {
32 | "missing": [],
33 | "module_doc": False,
34 | "missing_count": 0,
35 | "needed_count": 0,
36 | "coverage": 100.0,
37 | "empty": True,
38 | }
39 | }
40 | assert total_results == {"missing_count": 0, "needed_count": 0, "coverage": 100}
41 |
42 |
43 | @pytest.mark.parametrize(
44 | ["file_path", "needed_count"], [(DOCUMENTED_FILE_PATH, 11), (FULLY_EXCUSED_FILE_PATH, 10)]
45 | )
46 | def test_should_report_full_coverage(file_path, needed_count):
47 | result = analyze([file_path])
48 | file_results, total_results = result.to_legacy()
49 | assert file_results == {
50 | file_path: {
51 | "missing": [],
52 | "module_doc": True,
53 | "missing_count": 0,
54 | "needed_count": needed_count,
55 | "coverage": 100.0,
56 | "empty": False,
57 | }
58 | }
59 | assert total_results == {"missing_count": 0, "needed_count": needed_count, "coverage": 100.0}
60 |
61 |
62 | @pytest.mark.parametrize(
63 | ["file_path", "missing", "module_doc", "missing_count", "needed_count", "coverage"],
64 | [
65 | (PARTLY_DOCUMENTED_FILE_PATH, ["FooBar.__init__", "foo", "bar"], False, 4, 5, 20.0),
66 | (PARTLY_EXCUSED_FILE_PATH, ["FooBar.__init__", "bar"], True, 2, 8, 75.0),
67 | ],
68 | )
69 | def test_should_report_partial_coverage(
70 | file_path, missing, module_doc, missing_count, needed_count, coverage
71 | ):
72 | result = analyze([file_path])
73 | file_results, total_results = result.to_legacy()
74 | assert file_results == {
75 | file_path: {
76 | "missing": missing,
77 | "module_doc": module_doc,
78 | "missing_count": missing_count,
79 | "needed_count": needed_count,
80 | "coverage": coverage,
81 | "empty": False,
82 | }
83 | }
84 | assert total_results == {
85 | "missing_count": missing_count,
86 | "needed_count": needed_count,
87 | "coverage": coverage,
88 | }
89 |
90 |
91 | def test_should_report_for_multiple_files():
92 | result = analyze([PARTLY_DOCUMENTED_FILE_PATH, DOCUMENTED_FILE_PATH, EMPTY_FILE_PATH])
93 | file_results, total_results = result.to_legacy()
94 |
95 | assert file_results == {
96 | PARTLY_DOCUMENTED_FILE_PATH: {
97 | "missing": ["FooBar.__init__", "foo", "bar"],
98 | "module_doc": False,
99 | "missing_count": 4,
100 | "needed_count": 5,
101 | "coverage": 20.0,
102 | "empty": False,
103 | },
104 | DOCUMENTED_FILE_PATH: {
105 | "missing": [],
106 | "module_doc": True,
107 | "missing_count": 0,
108 | "needed_count": 11,
109 | "coverage": 100.0,
110 | "empty": False,
111 | },
112 | EMPTY_FILE_PATH: {
113 | "missing": [],
114 | "module_doc": False,
115 | "missing_count": 0,
116 | "needed_count": 0,
117 | "coverage": 100.0,
118 | "empty": True,
119 | },
120 | }
121 | assert total_results == {"missing_count": 4, "needed_count": 16, "coverage": 75.0}
122 |
123 |
124 | def test_should_report_when_no_docs_in_a_file():
125 | result = analyze([SOME_CODE_NO_DOCS_FILE_PATH])
126 | file_results, total_results = result.to_legacy()
127 | assert file_results == {
128 | SOME_CODE_NO_DOCS_FILE_PATH: {
129 | "missing": ["foo"],
130 | "module_doc": False,
131 | "missing_count": 2,
132 | "needed_count": 2,
133 | "coverage": 0.0,
134 | "empty": False,
135 | }
136 | }
137 | assert total_results == {"missing_count": 2, "needed_count": 2, "coverage": 0.0}
138 |
139 |
140 | ##################################################
141 | # Logging Tests
142 | ##################################################
143 | @pytest.mark.parametrize(
144 | ["expected"],
145 | [
146 | (
147 | [
148 | "",
149 | 'File: "tests/sample_files/subdir_a/empty_file.py"',
150 | " - File is empty",
151 | " Needed: 0; Found: 0; Missing: 0; Coverage: 100.0%",
152 | "",
153 | "",
154 | "Overall statistics (1 files are empty):",
155 | "Needed: 0 - Found: 0 - Missing: 0",
156 | "Total coverage: 100.0% - Grade: " + _GRADES[0][0],
157 | ],
158 | )
159 | ],
160 | )
161 | def test_legacy_printer_logging_empty_file(caplog, expected):
162 | with caplog.at_level(logging.DEBUG):
163 | result = analyze([EMPTY_FILE_PATH])
164 | LegacyPrinter(result, verbosity=4).print_to_stdout()
165 | _file_results, _total_results = result.to_legacy()
166 |
167 | if platform.system() == "Windows":
168 | assert [m.replace("\\", "/") for m in caplog.messages] == expected
169 | else:
170 | assert caplog.messages == expected
171 |
172 |
173 | @pytest.mark.parametrize(
174 | ["expected"],
175 | [
176 | (
177 | [
178 | "\n",
179 | 'File: "tests/sample_files/subdir_a/empty_file.py"\n',
180 | " - File is empty\n",
181 | " Needed: 0; Found: 0; Missing: 0; Coverage: 100.0%\n",
182 | "\n",
183 | "\n",
184 | "Overall statistics (1 files are empty):\n",
185 | "Needed: 0 - Found: 0 - Missing: 0\n",
186 | "Total coverage: 100.0% - Grade: " + _GRADES[0][0],
187 | ],
188 | )
189 | ],
190 | )
191 | def test_legacy_save_to_file_printer_empty_file(tmpdir, expected):
192 | path = tmpdir.join("coverage-result.txt")
193 | result = analyze([EMPTY_FILE_PATH])
194 | LegacyPrinter(result, verbosity=4).save_to_file(path.strpath)
195 |
196 | lines = path.readlines()
197 | if platform.system() == "Windows":
198 | assert [m.replace("\\", "/") for m in lines] == expected
199 | else:
200 | assert lines == expected
201 |
202 |
203 | @pytest.mark.parametrize(
204 | ["expected"],
205 | [
206 | (
207 | [
208 | "**File**: `tests/sample_files/subdir_a/empty_file.py`",
209 | "- File is empty",
210 | "",
211 | "| Needed | Found | Missing | Coverage |",
212 | "|---|---|---|---|",
213 | "| 0 | 0 | 0 | 100.0% |",
214 | "",
215 | "",
216 | "## Overall statistics",
217 | "",
218 | "Total coverage: **100.0%**",
219 | "",
220 | "Grade: **" + _GRADES[0][0] + "**",
221 | "- 1 files are empty",
222 | "",
223 | "| Needed | Found | Missing |",
224 | "|---|---|---|",
225 | "| 0 | 0 | 0 |",
226 | ],
227 | )
228 | ],
229 | )
230 | def test_markdown_printer_logging_empty_file(caplog, expected):
231 | with caplog.at_level(logging.DEBUG):
232 | result = analyze([EMPTY_FILE_PATH])
233 | MarkdownPrinter(result, verbosity=4).print_to_stdout()
234 | _file_results, _total_results = result.to_legacy()
235 |
236 | if platform.system() == "Windows":
237 | assert [m.replace("\\", "/") for m in caplog.messages] == expected
238 | else:
239 | assert caplog.messages == expected
240 |
241 |
242 | @pytest.mark.parametrize(
243 | ["expected"],
244 | [
245 | (
246 | [
247 | "**File**: `tests/sample_files/subdir_a/empty_file.py`\n",
248 | "- File is empty\n",
249 | "\n",
250 | "| Needed | Found | Missing | Coverage |\n",
251 | "|---|---|---|---|\n",
252 | "| 0 | 0 | 0 | 100.0% |\n",
253 | "\n",
254 | "\n",
255 | "## Overall statistics\n",
256 | "\n",
257 | "Total coverage: **100.0%**\n",
258 | "\n",
259 | "Grade: **" + _GRADES[0][0] + "**\n",
260 | "- 1 files are empty\n",
261 | "\n",
262 | "| Needed | Found | Missing |\n",
263 | "|---|---|---|\n",
264 | "| 0 | 0 | 0 |",
265 | ],
266 | )
267 | ],
268 | )
269 | def test_markdown_save_to_file_printer_empty_file(tmpdir, expected):
270 | path = tmpdir.join("coverage-result.md")
271 | result = analyze([EMPTY_FILE_PATH])
272 | MarkdownPrinter(result, verbosity=4).save_to_file(path.strpath)
273 |
274 | lines = path.readlines()
275 | if platform.system() == "Windows":
276 | assert [m.replace("\\", "/") for m in lines] == expected
277 | else:
278 | assert lines == expected
279 |
280 |
281 | @pytest.mark.parametrize(
282 | ["expected", "verbose", "ignore_names"],
283 | [
284 | (
285 | [
286 | "",
287 | 'File: "tests/sample_files/subdir_a/partly_documented_file.py"',
288 | " - No module docstring",
289 | " - No docstring for `foo`",
290 | " - No docstring for `bar`",
291 | " Needed: 4; Found: 1; Missing: 3; Coverage: 25.0%",
292 | "",
293 | "",
294 | "Overall statistics:",
295 | "Needed: 4 - Found: 1 - Missing: 3",
296 | "Total coverage: 25.0% - Grade: " + _GRADES[6][0],
297 | ],
298 | 3,
299 | ([".*", "__.+__"],),
300 | ),
301 | (
302 | [
303 | "",
304 | 'File: "tests/sample_files/subdir_a/partly_documented_file.py"',
305 | " - No module docstring",
306 | " - No docstring for `FooBar.__init__`",
307 | " - No docstring for `foo`",
308 | " - No docstring for `bar`",
309 | " Needed: 5; Found: 1; Missing: 4; Coverage: 20.0%",
310 | "",
311 | "",
312 | "Overall statistics:",
313 | "Needed: 5 - Found: 1 - Missing: 4",
314 | "Total coverage: 20.0% - Grade: " + _GRADES[7][0],
315 | ],
316 | 3,
317 | (),
318 | ),
319 | (
320 | [
321 | "",
322 | 'File: "tests/sample_files/subdir_a/partly_documented_file.py"',
323 | " Needed: 5; Found: 1; Missing: 4; Coverage: 20.0%",
324 | "",
325 | "",
326 | "Overall statistics:",
327 | "Needed: 5 - Found: 1 - Missing: 4",
328 | "Total coverage: 20.0% - Grade: " + _GRADES[7][0],
329 | ],
330 | 2,
331 | (),
332 | ),
333 | (
334 | [
335 | "Overall statistics:",
336 | "Needed: 5 - Found: 1 - Missing: 4",
337 | "Total coverage: 20.0% - Grade: " + _GRADES[7][0],
338 | ],
339 | 1,
340 | (),
341 | ),
342 | (
343 | [
344 | "Overall statistics:",
345 | "Needed: 1 - Found: 0 - Missing: 1",
346 | "Total coverage: 0.0% - Grade: " + _GRADES[9][0],
347 | ],
348 | 1,
349 | ([".*", ".*"],), # ignore all, except module
350 | ),
351 | ],
352 | )
353 | def test_legacy_printer_logging_partially_documented_file(caplog, expected, verbose, ignore_names):
354 | ignore_config = IgnoreConfig(ignore_names=ignore_names)
355 | with caplog.at_level(logging.DEBUG):
356 | result = analyze([PARTLY_DOCUMENTED_FILE_PATH], ignore_config=ignore_config)
357 | LegacyPrinter(result, verbosity=verbose, ignore_config=ignore_config).print_to_stdout()
358 |
359 | if platform.system() == "Windows":
360 | assert [m.replace("\\", "/") for m in caplog.messages] == expected
361 | else:
362 | assert caplog.messages == expected
363 |
364 |
365 | @pytest.mark.parametrize(
366 | ["expected", "verbose", "ignore_names"],
367 | [
368 | (
369 | [
370 | "**File**: `tests/sample_files/subdir_a/partly_documented_file.py`",
371 | "- No module docstring",
372 | "- No docstring for `foo`",
373 | "- No docstring for `bar`",
374 | "",
375 | "| Needed | Found | Missing | Coverage |",
376 | "|---|---|---|---|",
377 | "| 4 | 1 | 3 | 25.0% |",
378 | "",
379 | "",
380 | "## Overall statistics",
381 | "",
382 | "Total coverage: **25.0%**",
383 | "",
384 | "Grade: **" + _GRADES[6][0] + "**",
385 | "",
386 | "| Needed | Found | Missing |",
387 | "|---|---|---|",
388 | "| 4 | 1 | 3 |",
389 | ],
390 | 3,
391 | ([".*", "__.+__"],),
392 | ),
393 | (
394 | [
395 | "**File**: `tests/sample_files/subdir_a/partly_documented_file.py`",
396 | "- No module docstring",
397 | "- No docstring for `FooBar.__init__`",
398 | "- No docstring for `foo`",
399 | "- No docstring for `bar`",
400 | "",
401 | "| Needed | Found | Missing | Coverage |",
402 | "|---|---|---|---|",
403 | "| 5 | 1 | 4 | 20.0% |",
404 | "",
405 | "",
406 | "## Overall statistics",
407 | "",
408 | "Total coverage: **20.0%**",
409 | "",
410 | "Grade: **" + _GRADES[7][0] + "**",
411 | "",
412 | "| Needed | Found | Missing |",
413 | "|---|---|---|",
414 | "| 5 | 1 | 4 |",
415 | ],
416 | 3,
417 | (),
418 | ),
419 | (
420 | [
421 | "**File**: `tests/sample_files/subdir_a/partly_documented_file.py`",
422 | "",
423 | "| Needed | Found | Missing | Coverage |",
424 | "|---|---|---|---|",
425 | "| 5 | 1 | 4 | 20.0% |",
426 | "",
427 | "",
428 | "## Overall statistics",
429 | "",
430 | "Total coverage: **20.0%**",
431 | "",
432 | "Grade: **" + _GRADES[7][0] + "**",
433 | "",
434 | "| Needed | Found | Missing |",
435 | "|---|---|---|",
436 | "| 5 | 1 | 4 |",
437 | ],
438 | 2,
439 | (),
440 | ),
441 | (
442 | [
443 | "## Overall statistics",
444 | "",
445 | "Total coverage: **20.0%**",
446 | "",
447 | "Grade: **" + _GRADES[7][0] + "**",
448 | "",
449 | "| Needed | Found | Missing |",
450 | "|---|---|---|",
451 | "| 5 | 1 | 4 |",
452 | ],
453 | 1,
454 | (),
455 | ),
456 | (
457 | [
458 | "## Overall statistics",
459 | "",
460 | "Total coverage: **0.0%**",
461 | "",
462 | "Grade: **" + _GRADES[9][0] + "**",
463 | "",
464 | "| Needed | Found | Missing |",
465 | "|---|---|---|",
466 | "| 1 | 0 | 1 |",
467 | ],
468 | 1,
469 | ([".*", ".*"],), # ignore all, except module
470 | ),
471 | ],
472 | )
473 | def test_markdown_printer_logging_partially_documented_file(
474 | caplog, expected, verbose, ignore_names
475 | ):
476 | ignore_config = IgnoreConfig(ignore_names=ignore_names)
477 | with caplog.at_level(logging.DEBUG):
478 | result = analyze([PARTLY_DOCUMENTED_FILE_PATH], ignore_config=ignore_config)
479 | MarkdownPrinter(result, verbosity=verbose, ignore_config=ignore_config).print_to_stdout()
480 |
481 | if platform.system() == "Windows":
482 | assert [m.replace("\\", "/") for m in caplog.messages] == expected
483 | else:
484 | assert caplog.messages == expected
485 |
486 |
487 | def test_skip_private():
488 | ignore_config = IgnoreConfig(skip_private=True)
489 | result = analyze([PRIVATE_NO_DOCS_PATH], ignore_config=ignore_config)
490 | file_results, total_results = result.to_legacy()
491 | assert file_results[PRIVATE_NO_DOCS_PATH] == {
492 | "missing": ["__dunder", "__adunder"],
493 | "module_doc": True,
494 | "missing_count": 2,
495 | "needed_count": 3,
496 | "coverage": 33.333333333333336,
497 | "empty": False,
498 | }
499 | assert total_results == {"missing_count": 2, "needed_count": 3, "coverage": 33.333333333333336}
500 |
501 |
502 | def test_long_doc():
503 | """Regression test on issue 79.
504 |
505 | Multiline docstrings can be a smoke test when checking
506 | the tokenize tokens (which is based on line numbers)."""
507 | result = analyze([os.path.join(INDIVIDUAL_SAMPLES_DIR, "long_doc.py")])
508 | assert result.count_aggregate().coverage() == 75.0
509 | assert result.count_aggregate().num_files == 1
510 | # 2 + 1 inline ignore
511 | assert result.count_aggregate().found == 3
512 | assert result.count_aggregate().needed == 4
513 |
514 |
515 | @pytest.mark.parametrize(
516 | ["ignore_setter", "ignore_deleter", "ignore_property", "coverage"],
517 | [
518 | (False, False, False, 3 / 6),
519 | (True, False, False, 3 / 5),
520 | (False, True, False, 3 / 5),
521 | (False, False, True, 3 / 5),
522 | (True, True, True, 3 / 3),
523 | ],
524 | )
525 | def test_skip_decorators(ignore_setter, ignore_deleter, ignore_property, coverage):
526 | """Tests ignoring of property decorators"""
527 | ignore_config = IgnoreConfig(
528 | skip_setter=ignore_setter,
529 | skip_property=ignore_property,
530 | skip_deleter=ignore_deleter,
531 | )
532 | result = analyze([os.path.join(INDIVIDUAL_SAMPLES_DIR, "decorators.py")], ignore_config)
533 | assert result.count_aggregate().coverage() == coverage * 100
534 |
--------------------------------------------------------------------------------
/tests/test_housekeeping.py:
--------------------------------------------------------------------------------
1 | """Nonfunctional tests regarding manual configuration."""
2 | import re
3 |
4 |
5 | def test_version_consistency():
6 | """Tests that all version strings are consistent."""
7 |
8 | # Setup.py
9 | setup_version = "not-parsed"
10 | with open("setup.py", "r") as setup_file:
11 | for line in setup_file.readlines():
12 | if line.startswith("MAJOR, MINOR, MICRO"):
13 | setup_version = ".".join("".join(line.split()).split("=")[1].split(","))
14 | break
15 | # Documentation
16 | docs_version = "not-parsed"
17 | with open("docs/conf.py", "r") as setup_file:
18 | for line in setup_file.readlines():
19 | if line.startswith("release = "):
20 | docs_version = line.split('"')[1]
21 | break
22 | # Pre-Commit Example in README.md
23 | readme_hook_version = "not-parsed"
24 | with open("README.md", "r") as setup_file:
25 | for line in setup_file.readlines():
26 | # remove spaces
27 | line = "".join(line.split())
28 | if line.startswith("rev:v"):
29 | re_result = re.search("rev:v(.*)#mostrecent", line)
30 | readme_hook_version = re_result.group(1)
31 | break
32 |
33 | assert (
34 | setup_version == docs_version
35 | ), "version in docs ({}) does not match setup.py version ({})".format(
36 | docs_version, setup_version
37 | )
38 | assert (
39 | setup_version == readme_hook_version
40 | ), "version in readme ({}) does not match setup.py version ({})".format(
41 | readme_hook_version, setup_version
42 | )
43 |
--------------------------------------------------------------------------------
/tests/test_result_collection.py:
--------------------------------------------------------------------------------
1 | """The `result_collection` module is very simple and consists mostly of data-classes with
2 | little logic. Thus, the tests implemented here are mostly trivial and act primarily as
3 | smoke- and regression tests."""
4 | import os
5 | from typing import Dict
6 |
7 | import pytest
8 |
9 | from docstr_coverage.result_collection import (
10 | AggregatedCount,
11 | File,
12 | FileCount,
13 | FileStatus,
14 | ResultCollection,
15 | )
16 |
17 |
18 | def _path(*argv):
19 | """Creates test file paths from provided splits (folder and file names) with the
20 | current `os` file path separator"""
21 | return os.path.sep.join(argv)
22 |
23 |
24 | class TestResultCollection:
25 | """Tests the methods in the `ResultCollection` class"""
26 |
27 | def test_get_file(self):
28 | """Makes sure files are created and stored correctly, and no duplicates are created"""
29 | result_collection = ResultCollection()
30 | file_1 = result_collection.get_file(_path("some", "path", "file.py"))
31 | file_2 = result_collection.get_file(_path("some", "path", "file.py"))
32 | file_3 = result_collection.get_file(_path("some", "other", "file.py"))
33 | assert file_1 == file_2
34 | assert file_1 != file_3
35 |
36 | def test_to_legacy(self):
37 | """Sanity checks for conversion of `ResultCollection` objects to the legacy result format"""
38 | result_collection = ResultCollection()
39 | file_1 = result_collection.get_file(_path("my", "file.py"))
40 | file_1.collect_module_docstring(False)
41 | file_1.collect_docstring("method_x", True)
42 | file_1.collect_docstring("method_y", True)
43 | file_2 = result_collection.get_file(_path("my", "other", "file.py"))
44 | file_2.collect_module_docstring(False)
45 | file_2.collect_docstring("method_x", True)
46 | file_2.collect_docstring("method_y", True, "ignored")
47 | legacy_file_results, legacy_results = result_collection.to_legacy()
48 | assert isinstance(legacy_file_results, Dict)
49 | assert (
50 | legacy_file_results[_path("my", "file.py")]["missing"] == []
51 | ) # legacy does not list module docs
52 | assert legacy_file_results[_path("my", "file.py")]["missing_count"] == 1
53 | assert legacy_file_results[_path("my", "file.py")]["module_doc"] is False
54 |
55 | assert isinstance(legacy_results, Dict)
56 | assert legacy_results["missing_count"] == 2
57 | assert legacy_results["needed_count"] == 5
58 | assert legacy_results["coverage"] == 3 / 5 * 100
59 |
60 |
61 | class TestFile:
62 | """Tests the methods in the `File` class"""
63 |
64 | def test_count_and_iter(self):
65 | """Test that the iterator of expected (i.e., found or missing) docstrings is working, and
66 | that the counting works as expected (e.g., that it does not count ignored docstrings)"""
67 | file = File()
68 | file.collect_module_docstring(False)
69 | file.collect_docstring("method_one", True, "ignored_nonetheless")
70 | file.collect_docstring("method_two", False, "ignored")
71 | file.collect_docstring("method_three", False)
72 | file.collect_docstring("Class.method", True)
73 |
74 | expected_docstrings = [d for d in file.expected_docstrings()]
75 | assert len(expected_docstrings) == 5
76 | count = file.count_aggregate()
77 | assert count.is_empty is False
78 | assert count.found == 1
79 | assert count.missing == 2
80 | assert count.needed == 3
81 |
82 | def test_set_get_status(self):
83 | """Default settings and getter / setter of status"""
84 | file = File()
85 | assert file.status == FileStatus.ANALYZED
86 | file.status = FileStatus.EMPTY
87 | assert file.status == FileStatus.EMPTY
88 |
89 | @pytest.mark.parametrize(
90 | ["has_docstr", "ignore_reason"],
91 | [(True, None), (True, "excuse"), (False, None), (False, "excuse")],
92 | )
93 | def test_report_module(self, has_docstr, ignore_reason):
94 | """Tests that 'module' docstrings are recoded correctly"""
95 | file = File()
96 | file.collect_module_docstring(has_docstring=has_docstr, ignore_reason=ignore_reason)
97 | all_docstrings = [d for d in file.expected_docstrings()]
98 | assert len(all_docstrings) == 1
99 | assert all_docstrings[0].node_identifier == "module docstring"
100 | assert all_docstrings[0].has_docstring is has_docstr
101 | assert all_docstrings[0].ignore_reason is ignore_reason
102 |
103 | @pytest.mark.parametrize(
104 | ["node_identifier", "has_docstr", "ignore_reason"],
105 | [
106 | ("abc", True, None),
107 | ("abc.def", True, "excuse"),
108 | ("abc.Adc", False, None),
109 | ("asd_fsa", False, "excuse"),
110 | ],
111 | )
112 | def test_report_(self, node_identifier, has_docstr, ignore_reason):
113 | """Tests that 'non-module' docstrings are recoded correctly"""
114 | file = File()
115 | file.collect_docstring(
116 | identifier=node_identifier, has_docstring=has_docstr, ignore_reason=ignore_reason
117 | )
118 | all_docstrings = [d for d in file.expected_docstrings()]
119 | assert len(all_docstrings) == 1
120 | assert all_docstrings[0].node_identifier == node_identifier
121 | assert all_docstrings[0].has_docstring is has_docstr
122 | assert all_docstrings[0].ignore_reason is ignore_reason
123 |
124 |
125 | class TestAggregateCount:
126 | """Test methods of the `AggregatedCount` utility class"""
127 |
128 | @pytest.mark.parametrize(
129 | ["left", "right", "expected"],
130 | [
131 | (
132 | AggregatedCount(num_files=0, num_empty_files=0, needed=0, found=0, missing=0),
133 | AggregatedCount(num_files=0, num_empty_files=0, needed=0, found=0, missing=0),
134 | AggregatedCount(num_files=0, num_empty_files=0, needed=0, found=0, missing=0),
135 | ),
136 | (
137 | AggregatedCount(num_files=1, num_empty_files=3, needed=5, found=4, missing=1),
138 | AggregatedCount(num_files=2, num_empty_files=4, needed=3, found=2, missing=1),
139 | AggregatedCount(num_files=3, num_empty_files=7, needed=8, found=6, missing=2),
140 | ),
141 | ],
142 | )
143 | def test_add(self, left, right, expected):
144 | """Verifies the addition and the equal operator adds/compares all fields."""
145 | assert left + right == expected
146 | # Sanity check of equals function
147 | other_count = AggregatedCount(
148 | num_files=111, num_empty_files=5, needed=123, found=123, missing=0
149 | )
150 | assert left + right != other_count
151 |
152 | @pytest.mark.parametrize(
153 | ["agg_count", "coverage"],
154 | [
155 | (AggregatedCount(num_files=0, num_empty_files=0, needed=0, found=0, missing=0), 100.0),
156 | (
157 | AggregatedCount(num_files=1, num_empty_files=3, needed=5, found=4, missing=1),
158 | 4 / 5 * 100,
159 | ),
160 | (AggregatedCount(num_files=1, num_empty_files=3, needed=5, found=5, missing=0), 100.0),
161 | (AggregatedCount(num_files=1, num_empty_files=3, needed=5, found=0, missing=5), 0.0),
162 | ],
163 | )
164 | def test_coverage_calculation(self, agg_count, coverage):
165 | assert agg_count.coverage() == coverage
166 |
167 |
168 | class TestFileCount:
169 | """Test methods of the `FileCount` utility class"""
170 |
171 | def test_counter_empty(self):
172 | """Makes sure empty files are correctly tracked"""
173 | counter = FileCount()
174 | assert (
175 | counter.needed == 0
176 | and counter.missing == 0
177 | and counter.found == 0
178 | and counter.is_empty is False
179 | )
180 | counter.found_empty_file()
181 | assert (
182 | counter.needed == 0
183 | and counter.missing == 0
184 | and counter.found == 0
185 | and counter.is_empty is True
186 | )
187 |
188 | def test_counter_counts(self):
189 | """Makes sure the counting of 'found' and 'missing' docstrings works"""
190 | counter = FileCount()
191 | assert (
192 | counter.needed == 0
193 | and counter.missing == 0
194 | and counter.found == 0
195 | and counter.is_empty is False
196 | )
197 | counter.found_needed_docstr()
198 | counter.found_needed_docstr()
199 | assert (
200 | counter.needed == 2
201 | and counter.missing == 0
202 | and counter.found == 2
203 | and counter.is_empty is False
204 | )
205 | counter.missed_needed_docstring()
206 | assert (
207 | counter.needed == 3
208 | and counter.missing == 1
209 | and counter.found == 2
210 | and counter.is_empty is False
211 | )
212 |
--------------------------------------------------------------------------------