├── .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 | ![docstr-coverage](https://github.com/HunterMcGushion/docstr_coverage/raw/master/docs/logo_wide.png) 2 | 3 |

4 | 5 | 6 | 7 | 8 | Documentation Status 9 | 10 | 11 | PyPI 12 | 13 | 14 | Python Version 15 | 16 | 17 | Download count 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 | --------------------------------------------------------------------------------