├── .github ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── release.yml └── workflows │ ├── ci.yml │ ├── nightly.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── .readthedocs.yaml ├── HOW_TO_RELEASE.rst ├── LICENSE ├── README.rst ├── blackdoc ├── __init__.py ├── __main__.py ├── autoupdate.py ├── blackcompat.py ├── blacken.py ├── classification.py ├── colors.py ├── console.py ├── diff.py ├── files.py ├── formats │ ├── __init__.py │ ├── doctest.py │ ├── errors.py │ ├── ipython.py │ ├── none.py │ ├── register.py │ └── rst.py ├── report.py └── tests │ ├── __init__.py │ ├── data │ ├── __init__.py │ ├── __main__.py │ ├── doctest.py │ ├── ipython.py │ ├── rst.py │ └── utils.py │ ├── test_autoupdate.py │ ├── test_blacken.py │ ├── test_classification.py │ ├── test_colors.py │ ├── test_doctest.py │ ├── test_init.py │ ├── test_ipython.py │ ├── test_none.py │ ├── test_report.py │ └── test_rst.py ├── ci └── requirements │ ├── doc.txt │ ├── environment.yml │ ├── normal.txt │ └── upstream-dev.txt ├── dev-requirements.txt ├── doc ├── changelog.rst ├── conf.py ├── contributing.rst ├── directory │ ├── file.py │ ├── file.rst │ ├── reformatted.py │ └── reformatted.rst ├── index.rst ├── installing.rst ├── options.rst └── usage.rst ├── licenses └── BLACK_LICENSE └── pyproject.toml /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - [ ] Closes #xxxx 2 | - [ ] Tests added 3 | - [ ] Passes `pre-commit run --all-files` 4 | - [ ] User visible changes (including notable bug fixes) are documented in `changelog.rst` 5 | - [ ] New features are documented in the docs 6 | 7 | 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the action will run. Triggers the workflow on push or pull request 4 | # events but only for the main branch 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | detect-skip-ci-trigger: 17 | name: "Detect CI Trigger: [skip-ci]" 18 | runs-on: ubuntu-latest 19 | outputs: 20 | triggered: ${{ steps.detect-trigger.outputs.trigger-found }} 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 2 25 | - uses: xarray-contrib/ci-trigger@v1 26 | id: detect-trigger 27 | with: 28 | keyword: "[skip-ci]" 29 | 30 | unit-tests: 31 | name: ${{ matrix.os }} py${{ matrix.python-version }} 32 | runs-on: ${{ matrix.os }} 33 | needs: detect-skip-ci-trigger 34 | if: needs.detect-skip-ci-trigger.outputs.triggered == 'false' 35 | 36 | strategy: 37 | matrix: 38 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 39 | os: [ubuntu-latest, windows-latest, macos-latest] 40 | 41 | steps: 42 | - name: checkout the repository 43 | uses: actions/checkout@v4 44 | with: 45 | # need to fetch all tags to get a correct version 46 | fetch-depth: 0 # fetch all branches and tags 47 | 48 | - name: setup python 49 | uses: actions/setup-python@v5 50 | with: 51 | python-version: ${{ matrix.python-version }} 52 | 53 | - name: upgrade pip 54 | run: python -m pip install --upgrade pip 55 | 56 | - name: install dependencies 57 | run: | 58 | python -m pip install -r ci/requirements/normal.txt 59 | 60 | - name: install blackdoc 61 | run: python -m pip install . 62 | 63 | - name: show versions 64 | run: python -m pip list 65 | 66 | - name: run tests 67 | run: python -m pytest 68 | 69 | docs: 70 | name: Docs 71 | runs-on: ubuntu-latest 72 | 73 | steps: 74 | - name: checkout the repository 75 | uses: actions/checkout@v4 76 | 77 | - name: setup python 78 | uses: actions/setup-python@v5 79 | with: 80 | python-version: "3.12" 81 | 82 | - name: upgrade pip 83 | run: python -m pip install --upgrade pip 84 | 85 | - name: install dependencies 86 | run: python -m pip install -r ci/requirements/doc.txt 87 | 88 | - name: show versions 89 | run: python -m pip list 90 | 91 | - name: run sphinx 92 | run: | 93 | cd doc 94 | python -m sphinx -M html -d _build/doctrees -Ea -WT --keep-going -n . _build/html 95 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Nightly CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: "0 0 * * *" # Daily "At 00:00" UTC 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | detect-test-upstream-trigger: 18 | name: "Detect CI Trigger: [test-upstream]" 19 | if: | 20 | github.repository_owner == 'keewis' 21 | && (github.event_name == 'push' || github.event_name == 'pull_request') 22 | runs-on: ubuntu-latest 23 | outputs: 24 | triggered: ${{ steps.detect-trigger.outputs.trigger-found }} 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 2 29 | - uses: xarray-contrib/ci-trigger@v1 30 | id: detect-trigger 31 | with: 32 | keyword: "[test-upstream]" 33 | 34 | upstream-dev: 35 | name: upstream-dev 36 | runs-on: ubuntu-latest 37 | needs: detect-test-upstream-trigger 38 | 39 | if: | 40 | always() 41 | && github.repository_owner == 'keewis' 42 | && ( 43 | (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') 44 | || needs.detect-test-upstream-trigger.outputs.triggered == 'true' 45 | || ( 46 | github.event_name == 'pull_request' 47 | && contains(github.event.pull_request.labels.*.name, 'run-upstream') 48 | ) 49 | ) 50 | 51 | strategy: 52 | fail-fast: false 53 | matrix: 54 | python-version: ["3.12"] 55 | 56 | outputs: 57 | artifacts_availability: ${{ steps.status.outputs.ARTIFACTS_AVAILABLE }} 58 | 59 | steps: 60 | - name: checkout the repository 61 | uses: actions/checkout@v4 62 | with: 63 | # need to fetch all tags to get a correct version 64 | fetch-depth: 0 # fetch all branches and tags 65 | 66 | - name: setup python 67 | uses: actions/setup-python@v5 68 | with: 69 | python-version: ${{ matrix.python-version }} 70 | 71 | - name: upgrade pip 72 | run: python -m pip install --upgrade pip 73 | 74 | - name: install dependencies 75 | run: | 76 | python -m pip install -r ci/requirements/upstream-dev.txt 77 | 78 | - name: install blackdoc 79 | run: python -m pip install . 80 | 81 | - name: show versions 82 | run: python -m pip list 83 | 84 | - name: import and run blackdoc 85 | run: | 86 | python -c 'import blackdoc' 87 | python -m blackdoc 88 | 89 | - name: run tests 90 | if: success() 91 | id: tests 92 | run: | 93 | python -m pytest --report-log=pytest-log.jsonl 94 | 95 | - name: report failures 96 | if: | 97 | failure() 98 | && steps.tests.outcome == 'failure' 99 | && github.event_name == 'schedule' 100 | uses: xarray-contrib/issue-from-pytest-log@v1 101 | with: 102 | log-path: pytest-log.jsonl 103 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload package to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | name: Build packages 10 | runs-on: ubuntu-latest 11 | if: github.repository == 'keewis/blackdoc' 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.x" 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install build twine 23 | - name: Build 24 | run: | 25 | python -m build --outdir dist/ . 26 | - name: Check the built archives 27 | run: | 28 | twine check dist/* 29 | pip install dist/*.whl 30 | python -m blackdoc --version 31 | - name: Upload build artifacts 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: packages 35 | path: dist/* 36 | 37 | publish: 38 | name: Upload to PyPI 39 | runs-on: ubuntu-latest 40 | needs: build 41 | if: github.event_name == 'release' 42 | 43 | environment: 44 | name: pypi 45 | url: https://pypi.org/p/blackdoc 46 | permissions: 47 | id-token: write 48 | 49 | steps: 50 | - name: Download build artifacts 51 | uses: actions/download-artifact@v4 52 | with: 53 | name: packages 54 | path: dist/ 55 | 56 | - name: Publish to PyPI 57 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc 58 | with: 59 | verify_metadata: true 60 | verbose: true 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | coverage.xml 46 | *.cover 47 | *.py,cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Sphinx documentation 56 | doc/_build/ 57 | 58 | # Jupyter Notebook 59 | .ipynb_checkpoints 60 | 61 | # IPython 62 | profile_default/ 63 | ipython_config.py 64 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_prs: true 3 | autoupdate_schedule: monthly 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v5.0.0 8 | hooks: 9 | - id: trailing-whitespace 10 | - id: end-of-file-fixer 11 | - id: check-docstring-first 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.11.8 14 | hooks: 15 | - id: ruff 16 | args: ["--fix", "--show-fixes"] 17 | - repo: https://github.com/psf/black 18 | rev: 25.1.0 19 | hooks: 20 | - id: black 21 | - repo: https://github.com/rbubley/mirrors-prettier 22 | rev: v3.5.3 23 | hooks: 24 | - id: prettier 25 | - repo: https://github.com/ComPWA/taplo-pre-commit 26 | rev: v0.9.3 27 | hooks: 28 | - id: taplo-format 29 | args: ["--option", "array_auto_collapse=false"] 30 | - id: taplo-lint 31 | args: ["--no-schema"] 32 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: blackdoc 2 | name: blackdoc 3 | entry: blackdoc 4 | language: python 5 | language_version: python3 6 | require_serial: true 7 | types: [file] 8 | types_or: [python, rst] 9 | 10 | - id: blackdoc-autoupdate-black 11 | name: autoupdate-black 12 | entry: python -m blackdoc.autoupdate 13 | language: python 14 | language_version: python3 15 | require_serial: true 16 | types: [file] 17 | files: ^\.pre-commit-config\.yaml$ 18 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.12" 7 | 8 | python: 9 | install: 10 | - requirements: ci/requirements/doc.txt 11 | 12 | sphinx: 13 | configuration: doc/conf.py 14 | fail_on_warning: true 15 | 16 | formats: [] 17 | -------------------------------------------------------------------------------- /HOW_TO_RELEASE.rst: -------------------------------------------------------------------------------- 1 | Release process 2 | =============== 3 | 1. the release happens from `main` so make sure it is up-to-date: 4 | 5 | .. code:: sh 6 | 7 | git pull origin main 8 | 9 | 2. look at `changelog.rst` and make sure it is complete and with 10 | references to issues and pull requests 11 | 12 | 3. run the test suite, try to call the program and make sure the 13 | pre-commit hook works 14 | 15 | 4. check that the documentation is building 16 | 17 | 5. Update the release date and commit the release: 18 | 19 | .. code:: sh 20 | 21 | git commit -am "Release v0.X.Y" 22 | 23 | 6. Tag the release: 24 | 25 | .. code:: sh 26 | 27 | git tag -a v0.X.Y -m "v0.X.Y" 28 | 29 | 7. Build source and binary wheels: 30 | 31 | .. code:: sh 32 | 33 | git clean -xdf 34 | python -m pep517.build --source --binary . 35 | 36 | 8. Use `twine` to check the package build: 37 | 38 | .. code:: sh 39 | 40 | twine check dist/* 41 | 42 | 9. try installing the wheel in a new environment and run the tests / 43 | binary again: 44 | 45 | .. code:: bash 46 | 47 | python -m venv test 48 | source test/bin/activate 49 | python -m pip install -r dev-requirements.txt 50 | python -m pip install pytest 51 | python -m pip install dist/*.whl 52 | python -m pytest 53 | python -m blackdoc --check .; echo $? 54 | python -m blackdoc .; echo $? 55 | git reset --hard HEAD 56 | deactivate 57 | git clean -xdf 58 | 59 | 10. Push to main: 60 | 61 | .. code:: sh 62 | 63 | git push origin main 64 | git push origin --tags 65 | 66 | 11. Update stable: 67 | 68 | .. code:: sh 69 | 70 | git checkout stable 71 | git merge v0.X.Y 72 | git push origin stable 73 | 74 | 12. Make sure readthedocs builds both `stable` and the new tag 75 | 76 | 13. Draft a release on Github. Be careful, this can't be undone. 77 | 78 | A workflow will then publish to PyPI, which in turn will be picked up by conda-forge 79 | and a PR will be opened automatically on the feedstock. 80 | 81 | 14. Add a new section to the changelog and push directly to main 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 keewis 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. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | blackdoc 2 | ======== 3 | 4 | .. image:: https://github.com/keewis/blackdoc/workflows/CI/badge.svg?branch=main 5 | :target: https://github.com/keewis/blackdoc/actions 6 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 7 | :target: https://github.com/python/black 8 | .. image:: https://readthedocs.org/projects/blackdoc/badge/?version=latest 9 | :target: https://blackdoc.readthedocs.io/en/latest/?badge=latest 10 | :alt: Documentation Status 11 | 12 | **blackdoc** is a tool that applies `black`_ to code in documentation. 13 | 14 | It was originally a rewrite of a `gist`_ and called 15 | **black-doctest**. In April 2020, it was renamed to **blackdoc**. 16 | 17 | .. _gist: https://gist.github.com/mattharrison/2a1a263597d80e99cf85e898b800ec32 18 | .. _black: https://github.com/psf/black 19 | 20 | Installation 21 | ------------ 22 | Dependencies: 23 | 24 | - `black`_ 25 | - `more-itertools`_ 26 | - `rich`_ 27 | - `tomli`_ 28 | - `pathspec`_ 29 | 30 | .. _more-itertools: https://github.com/more-itertools/more-itertools 31 | .. _rich: https://github.com/textualize/rich 32 | .. _tomli: https://github.com/hukkin/tomli 33 | .. _pathspec: https://github.com/cpburnz/python-pathspec 34 | 35 | Install it with: 36 | 37 | .. code:: bash 38 | 39 | python -m pip install blackdoc 40 | 41 | Usage 42 | ----- 43 | The commandline interface supports two modes: checking and inplace 44 | reformatting. 45 | 46 | .. code:: bash 47 | 48 | python -m blackdoc --help 49 | 50 | 51 | In inplace reformatting mode, it will reformat the doctest lines and 52 | write them back to disk: 53 | 54 | .. code:: bash 55 | 56 | # on explicitly mentioned files 57 | python -m blackdoc file1.py file2.py 58 | # on the whole directory 59 | python -m blackdoc . 60 | 61 | 62 | When checking, it will report the changed files but will not write them to disk: 63 | 64 | .. code:: bash 65 | 66 | python -m blackdoc --check . 67 | 68 | It is also possible to use the entrypoint script: 69 | 70 | .. code:: bash 71 | 72 | blackdoc --help 73 | 74 | pre-commit 75 | ---------- 76 | This repository defines a ``pre-commit`` hook: 77 | 78 | .. code:: yaml 79 | 80 | hooks: 81 | ... 82 | - repo: https://github.com/keewis/blackdoc 83 | rev: 3.8.0 84 | hooks: 85 | - id: blackdoc 86 | 87 | It is recommended to *pin* ``black`` in order to avoid having different versions for each contributor. To automatically synchronize this pin with the version of the ``black`` hook, use the ``blackdoc-autoupdate-black`` hook: 88 | 89 | .. code:: yaml 90 | 91 | hooks: 92 | ... 93 | - repo: https://github.com/psf/black 94 | rev: 23.10.1 95 | hooks: 96 | - id: black 97 | ... 98 | - repo: https://github.com/keewis/blackdoc 99 | rev: 3.8.0 100 | hooks: 101 | - id: blackdoc 102 | additional_dependencies: ["black==23.10.1"] 103 | - id: blackdoc-autoupdate-black 104 | 105 | Note that this hook is *not* run on ``pre-commit autoupdate``. 106 | -------------------------------------------------------------------------------- /blackdoc/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version 2 | 3 | from blackdoc.blacken import blacken 4 | from blackdoc.classification import detect_format 5 | from blackdoc.formats import InvalidFormatError, register_format # noqa: F401 6 | 7 | try: 8 | __version__ = version("blackdoc") 9 | except Exception: 10 | # Local copy or not installed with setuptools. 11 | # Disable minimum version checks on downstream libraries. 12 | __version__ = "999" 13 | 14 | 15 | def line_numbers(lines): 16 | yield from enumerate(lines, start=1) 17 | 18 | 19 | def format_lines(lines, mode=None): 20 | numbered = line_numbers(lines) 21 | 22 | labeled = detect_format(numbered) 23 | blackened = blacken(labeled, mode=mode) 24 | 25 | return blackened 26 | -------------------------------------------------------------------------------- /blackdoc/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import pathlib 3 | import sys 4 | 5 | import black 6 | from rich.text import Text 7 | 8 | from blackdoc import __version__, format_lines, formats 9 | from blackdoc.blackcompat import read_pyproject_toml 10 | from blackdoc.colors import DiffHighlighter 11 | from blackdoc.console import err, out 12 | from blackdoc.diff import unified_diff 13 | from blackdoc.files import collect_files 14 | from blackdoc.report import Report 15 | 16 | diff_highlighter = DiffHighlighter() 17 | 18 | 19 | def check_format_names(string): 20 | names = string.split(",") 21 | allowed_names = set(formats.detection_funcs.keys()) - set(["none"]) 22 | for name in names: 23 | if name in allowed_names: 24 | continue 25 | 26 | raise argparse.ArgumentTypeError( 27 | f"invalid choice: {name!r} (choose from {', '.join(sorted(allowed_names))})" 28 | ) 29 | return names 30 | 31 | 32 | def format_and_overwrite(path, mode): 33 | try: 34 | with open(path, mode="rb") as f: 35 | content, encoding, newline = black.decode_bytes(f.read()) 36 | 37 | lines = content.split("\n") 38 | 39 | new_content = "\n".join(format_lines(lines, mode)) 40 | 41 | if new_content == content: 42 | result = "unchanged" 43 | else: 44 | err.print(f"reformatted {path}", style="bold", highlight=False) 45 | result = "reformatted" 46 | 47 | with open(path, "w", encoding=encoding, newline=newline) as f: 48 | f.write(new_content) 49 | except (black.InvalidInput, formats.InvalidFormatError) as e: 50 | err.print( 51 | f"error: cannot format {path.absolute()}: {e}", style="red", highlight=False 52 | ) 53 | result = "error" 54 | 55 | return result 56 | 57 | 58 | def format_and_check(path, mode, diff=False, color=False): 59 | try: 60 | with open(path, mode="rb") as f: 61 | content, _, _ = black.decode_bytes(f.read()) 62 | 63 | lines = content.split("\n") 64 | 65 | new_content = "\n".join(format_lines(lines, mode)) 66 | 67 | if new_content == content: 68 | result = "unchanged" 69 | else: 70 | err.print(f"would reformat {path}", style="bold", highlight=False) 71 | 72 | if diff: 73 | diff_ = unified_diff(content, new_content, path) 74 | 75 | if color: 76 | formatted_diff = diff_highlighter(diff_) 77 | else: 78 | formatted_diff = Text(diff_) 79 | 80 | out.print(formatted_diff) 81 | 82 | result = "reformatted" 83 | except (black.InvalidInput, formats.InvalidFormatError) as e: 84 | err.print( 85 | f"error: cannot format {path.absolute()}: {e}", style="red", highlight=False 86 | ) 87 | result = "error" 88 | 89 | return result 90 | 91 | 92 | def process(args): 93 | if not args.src: 94 | err.print("No Path provided. Nothing to do :sleeping:", style="bold") 95 | return 0 96 | 97 | selected_formats = getattr(args, "formats", None) 98 | if selected_formats: 99 | formats.disable( 100 | set(formats.detection_funcs.keys()) - set(selected_formats) - {"none"} 101 | ) 102 | 103 | disabled_formats = getattr(args, "disable_formats", None) 104 | if disabled_formats: 105 | formats.disable(disabled_formats) 106 | 107 | try: 108 | include_regex = black.re_compile_maybe_verbose(args.include) 109 | except black.re.error: 110 | err.print( 111 | f"Invalid regular expression for include given: {args.include!r}", 112 | style="red", 113 | ) 114 | return 2 115 | 116 | try: 117 | exclude_regex = black.re_compile_maybe_verbose(args.exclude) 118 | except black.re.error: 119 | err.print( 120 | f"Invalid regular expression for exclude given: {args.exclude!r}", 121 | style="red", 122 | ) 123 | return 2 124 | 125 | try: 126 | extend_exclude_regex = black.re_compile_maybe_verbose(args.extend_exclude) 127 | except black.re.error: 128 | err.print( 129 | f"Invalid regular expression for extend exclude given: {args.extend_exclude!r}", 130 | style="red", 131 | ) 132 | return 2 133 | 134 | try: 135 | force_exclude = getattr(args, "force_exclude", "") 136 | force_exclude_regex = ( 137 | black.re_compile_maybe_verbose(force_exclude) if force_exclude else None 138 | ) 139 | except black.re.error: 140 | err.print( 141 | f"Invalid regular expression for force_exclude given: {force_exclude!r}", 142 | style="red", 143 | ) 144 | return 2 145 | 146 | sources = set( 147 | collect_files( 148 | args.src, 149 | include_regex, 150 | exclude_regex, 151 | extend_exclude_regex, 152 | force_exclude_regex, 153 | quiet=args.quiet, 154 | verbose=args.verbose, 155 | ) 156 | ) 157 | if len(sources) == 0: 158 | err.print( 159 | "No files are present to be formatted. Nothing to do :sleeping:", 160 | style="bold", 161 | ) 162 | return 0 163 | 164 | target_versions = set( 165 | black.TargetVersion[version.upper()] 166 | for version in getattr(args, "target_versions", ()) 167 | ) 168 | mode = black.Mode( 169 | line_length=args.line_length, 170 | target_versions=target_versions, 171 | string_normalization=not args.skip_string_normalization, 172 | ) 173 | 174 | actions = { 175 | "inplace": format_and_overwrite, 176 | "check": format_and_check, 177 | } 178 | action_kwargs = {"diff": args.diff, "color": args.color} if args.diff else {} 179 | 180 | action = actions.get(args.action) 181 | 182 | changed_sources = { 183 | source: action(source, mode, **action_kwargs) for source in sorted(sources) 184 | } 185 | 186 | conditional = args.action == "check" 187 | report = Report.from_sources(changed_sources, conditional=conditional) 188 | 189 | if report.n_error > 0: 190 | return_code = 123 191 | elif args.action == "check" and report.n_reformatted > 0: 192 | return_code = 1 193 | else: 194 | return_code = 0 195 | 196 | error_message = "Oh no! :boom: :broken_heart: :boom:" 197 | no_error_message = "All done! :sparkles: :cake: :sparkles:" 198 | err.print() 199 | err.print( 200 | error_message if report.n_error > 0 else no_error_message, 201 | style="bold", 202 | ) 203 | err.print(report, highlight=False) 204 | return return_code 205 | 206 | 207 | class boolean_flag(argparse.Action): 208 | def __init__(self, option_strings, dest, nargs=None, **kwargs): 209 | super().__init__(option_strings, dest, nargs=0, **kwargs) 210 | 211 | def __call__(self, parser, namespace, values, option_string=None): 212 | value = False if option_string.startswith("--no-") else True 213 | setattr(namespace, self.dest, value) 214 | 215 | 216 | def main(): 217 | program = pathlib.Path(__file__).parent.name 218 | 219 | parser = argparse.ArgumentParser( 220 | description="run black on documentation code snippets (e.g. doctest)", 221 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 222 | prog=program, 223 | ) 224 | parser.add_argument( 225 | "-t", 226 | "--target-versions", 227 | action="append", 228 | choices=[v.name.lower() for v in black.TargetVersion], 229 | help=( 230 | "Python versions that should be supported by Black's output." 231 | " (default: per-file auto-detection)" 232 | ), 233 | default=argparse.SUPPRESS, 234 | ) 235 | parser.add_argument( 236 | "-l", 237 | "--line-length", 238 | metavar="INT", 239 | type=int, 240 | default=black.DEFAULT_LINE_LENGTH, 241 | help="How many characters per line to allow.", 242 | ) 243 | parser.add_argument( 244 | "--check", 245 | dest="action", 246 | action="store_const", 247 | const="check", 248 | default="inplace", 249 | help=( 250 | "Don't write the files back, just return the status. Return code 0" 251 | " means nothing would change. Return code 1 means some files would be" 252 | " reformatted. Return code 123 means there was an internal error." 253 | ), 254 | ) 255 | parser.add_argument( 256 | "--diff", 257 | dest="diff", 258 | action="store_const", 259 | const="diff", 260 | help="Don't write the files back, just output a diff for each file on stdout.", 261 | ) 262 | parser.add_argument( 263 | "--color", 264 | "--no-color", 265 | dest="color", 266 | action=boolean_flag, 267 | default=False, 268 | help="Show colored diff. Only applies when `--diff` is given.", 269 | ) 270 | parser.add_argument( 271 | "--include", 272 | metavar="TEXT", 273 | type=str, 274 | default=formats.format_include_patterns(), 275 | help=( 276 | "A regular expression that matches files and directories that should be" 277 | " included on recursive searches. An empty value means all files are" 278 | " included regardless of the name. Use forward slashes for directories on" 279 | " all platforms (Windows, too). Exclusions are calculated first, inclusions" 280 | " later." 281 | ), 282 | ) 283 | parser.add_argument( 284 | "--exclude", 285 | metavar="TEXT", 286 | type=str, 287 | default=black.DEFAULT_EXCLUDES, 288 | help=( 289 | "A regular expression that matches files and directories that should be" 290 | " excluded on recursive searches. An empty value means no paths are excluded." 291 | " Use forward slashes for directories on all platforms (Windows, too)." 292 | " Exclusions are calculated first, inclusions later." 293 | ), 294 | ) 295 | parser.add_argument( 296 | "--extend-exclude", 297 | metavar="TEXT", 298 | type=str, 299 | default="", 300 | help=( 301 | "Like --exclude, but adds additional files and directories" 302 | " on top of the excluded ones. (Useful if you simply want to" 303 | " add to the default)" 304 | ), 305 | ) 306 | parser.add_argument( 307 | "--force-exclude", 308 | metavar="TEXT", 309 | type=str, 310 | default=argparse.SUPPRESS, 311 | help=( 312 | "Like --exclude, but files and directories" 313 | " matching this regex will be excluded even" 314 | " when they are passed explicitly as arguments" 315 | ), 316 | ) 317 | parser.add_argument( 318 | "--formats", 319 | metavar="FMT[,FMT[,FMT...]]", 320 | type=check_format_names, 321 | help="Use only the specified formats.", 322 | default=argparse.SUPPRESS, 323 | ) 324 | parser.add_argument( 325 | "--disable-formats", 326 | metavar="FMT[,FMT[,FMT...]]", 327 | type=check_format_names, 328 | help=( 329 | "Disable the given formats." 330 | " This option also affects formats explicitly set." 331 | ), 332 | default=argparse.SUPPRESS, 333 | ) 334 | parser.add_argument( 335 | "-S", 336 | "--skip-string-normalization", 337 | dest="skip_string_normalization", 338 | action="store_true", 339 | help="Don't normalize string quotes or prefixes.", 340 | ) 341 | parser.add_argument( 342 | "-q", 343 | "--quiet", 344 | action="store_true", 345 | help=( 346 | "Don't emit non-error messages to stderr. Errors are still" 347 | " emitted; silence those with 2>/dev/null." 348 | ), 349 | ) 350 | parser.add_argument( 351 | "-v", 352 | "--verbose", 353 | action="store_true", 354 | help=( 355 | "Also emit messages to stderr about files that were not" 356 | " changed or were ignored due to exclusion patterns." 357 | ), 358 | ) 359 | parser.add_argument( 360 | "--version", 361 | action="version", 362 | help="Show the version and exit.", 363 | version=f"{program} {__version__}", 364 | ) 365 | parser.add_argument( 366 | "--config", 367 | action="store", 368 | nargs=1, 369 | type=pathlib.Path, 370 | default=None, 371 | help="Read configuration from FILE path.", 372 | ) 373 | parser.add_argument( 374 | "src", 375 | action="store", 376 | type=pathlib.Path, 377 | nargs="*", 378 | default=None, 379 | help="one or more paths to work on", 380 | ) 381 | args = parser.parse_args() 382 | if args.config or args.src: 383 | file_defaults = read_pyproject_toml(tuple(args.src), args.config) 384 | parser.set_defaults(**file_defaults) 385 | 386 | if args.diff: 387 | parser.set_defaults(action="check") 388 | 389 | args = parser.parse_args() 390 | sys.exit(process(args)) 391 | 392 | 393 | if __name__ == "__main__": 394 | main() 395 | -------------------------------------------------------------------------------- /blackdoc/autoupdate.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import re 3 | 4 | version_re = re.compile( 5 | r"https://github.com/psf/(?:black|black-pre-commit-mirror)\s+" 6 | r"rev: (.+)\s+hooks:(?:\s+-id: [-_a-zA-Z0-9]+)*\s+- id: (?:black|black-jupyter)" 7 | ) 8 | black_pin_re = re.compile( 9 | r"(- id: blackdoc.+?additional_dependencies:.+?black==)[.\w]+", 10 | re.DOTALL, 11 | ) 12 | 13 | 14 | def find_black_version(content): 15 | match = version_re.search(content) 16 | if match is None: 17 | raise ValueError("cannot find the black hook") 18 | version = match.group(1) 19 | return version 20 | 21 | 22 | def update_black_pin(content, version): 23 | return black_pin_re.sub(rf"\g<1>{version}", content) 24 | 25 | 26 | def main(path): 27 | with open(path) as f: 28 | content = f.read() 29 | 30 | version = find_black_version(content) 31 | replaced = update_black_pin(content, version) 32 | 33 | if content != replaced: 34 | with open(path, mode="w") as f: 35 | f.write(replaced) 36 | return 1 37 | else: 38 | return 0 39 | 40 | 41 | if __name__ == "__main__": 42 | parser = argparse.ArgumentParser() 43 | parser.add_argument("path") 44 | args = parser.parse_args() 45 | raise SystemExit(main(args.path)) 46 | -------------------------------------------------------------------------------- /blackdoc/blackcompat.py: -------------------------------------------------------------------------------- 1 | """vendored code copied from black 2 | 3 | For the license, see /licenses/black 4 | """ 5 | 6 | import os 7 | import sys 8 | from functools import lru_cache 9 | from pathlib import Path 10 | 11 | import tomli 12 | from pathspec import PathSpec 13 | 14 | 15 | @lru_cache 16 | def find_project_root(srcs): 17 | """Return a directory containing .git, .hg, or pyproject.toml. 18 | 19 | That directory will be a common parent of all files and directories 20 | passed in `srcs`. 21 | 22 | If no directory in the tree contains a marker that would specify it's the 23 | project root, the root of the file system is returned. 24 | 25 | Returns a two-tuple with the first element as the project root path and 26 | the second element as a string describing the method by which the 27 | project root was discovered. 28 | """ 29 | if not srcs: 30 | srcs = [str(Path.cwd().resolve())] 31 | 32 | path_srcs = [Path(Path.cwd(), src).resolve() for src in srcs] 33 | 34 | # A list of lists of parents for each 'src'. 'src' is included as a 35 | # "parent" of itself if it is a directory 36 | src_parents = [ 37 | list(path.parents) + ([path] if path.is_dir() else []) for path in path_srcs 38 | ] 39 | 40 | common_base = max( 41 | set.intersection(*(set(parents) for parents in src_parents)), 42 | key=lambda path: path.parts, 43 | ) 44 | 45 | for directory in (common_base, *common_base.parents): 46 | if (directory / ".git").exists(): 47 | return directory, ".git directory" 48 | 49 | if (directory / ".hg").is_dir(): 50 | return directory, ".hgdirectory" 51 | 52 | if (directory / "pyproject.toml").is_file(): 53 | return directory, "pyproject.toml" 54 | 55 | return directory, "file system root" 56 | 57 | 58 | def find_pyproject_toml(path_search_start): 59 | """Find the absolute filepath to a pyproject.toml if it exists""" 60 | path_project_root, _ = find_project_root(path_search_start) 61 | path_pyproject_toml = path_project_root / "pyproject.toml" 62 | if path_pyproject_toml.is_file(): 63 | return str(path_pyproject_toml) 64 | 65 | try: 66 | path_user_pyproject_toml = find_user_pyproject_toml() 67 | return ( 68 | str(path_user_pyproject_toml) 69 | if path_user_pyproject_toml.is_file() 70 | else None 71 | ) 72 | except (PermissionError, RuntimeError) as e: 73 | # We do not have access to the user-level config directory, so ignore it. 74 | print(f"Ignoring user configuration directory due to {e!r}") 75 | return None 76 | 77 | 78 | @lru_cache 79 | def find_user_pyproject_toml(): 80 | r"""Return the path to the top-level user configuration for black. 81 | 82 | This looks for ~\.black on Windows and ~/.config/black on Linux and other 83 | Unix systems. 84 | 85 | May raise: 86 | - RuntimeError: if the current user has no homedir 87 | - PermissionError: if the current process cannot access the user's homedir 88 | """ 89 | if sys.platform == "win32": 90 | # Windows 91 | user_config_path = Path.home() / ".black" 92 | else: 93 | config_root = os.environ.get("XDG_CONFIG_HOME", "~/.config") 94 | user_config_path = Path(config_root).expanduser() / "black" 95 | return user_config_path.resolve() 96 | 97 | 98 | def parse_pyproject_toml(path_config): 99 | """Parse a pyproject toml file, pulling out relevant parts for Black and Blackdoc 100 | 101 | If parsing fails, will raise a tomli.TOMLDecodeError 102 | """ 103 | with open(path_config, "rb") as f: 104 | pyproject_toml = tomli.load(f) 105 | 106 | black_config = pyproject_toml.get("tool", {}).get("black", {}) 107 | blackdoc_config = pyproject_toml.get("tool", {}).get("blackdoc", {}) 108 | config = {**black_config, **blackdoc_config} 109 | 110 | return {k.replace("--", "").replace("-", "_"): v for k, v in config.items()} 111 | 112 | 113 | def read_pyproject_toml(source, config_path): 114 | if not config_path: 115 | config_path = find_pyproject_toml(source) 116 | if config_path is None: 117 | return {} 118 | 119 | try: 120 | config = parse_pyproject_toml(config_path) 121 | except (ValueError, OSError) as e: 122 | raise OSError( 123 | f"Error reading configuration file ({config_path}): {e}" 124 | ) from None 125 | 126 | if not config: 127 | return {} 128 | 129 | return config 130 | 131 | 132 | def normalize_path_maybe_ignore(path, root, report): 133 | """Normalize `path`. May return `None` if `path` was ignored. 134 | 135 | `report` is where "path ignored" output goes. 136 | """ 137 | try: 138 | abspath = path if path.is_absolute() else Path.cwd() / path 139 | normalized_path = abspath.resolve().relative_to(root).as_posix() 140 | except OSError as e: 141 | report.path_ignored(path, f"cannot be read because {e}") 142 | return None 143 | 144 | except ValueError: 145 | if path.is_symlink(): 146 | report.path_ignored(path, f"is a symbolic link that points outside {root}") 147 | return None 148 | 149 | raise 150 | 151 | return normalized_path 152 | 153 | 154 | def path_is_excluded(normalized_path, pattern): 155 | match = pattern.search(normalized_path) if pattern else None 156 | return bool(match and match.group(0)) 157 | 158 | 159 | @lru_cache 160 | def get_gitignore(root): 161 | """Return a PathSpec matching gitignore content if present.""" 162 | gitignore = root / ".gitignore" 163 | lines = [] 164 | if gitignore.is_file(): 165 | with gitignore.open(encoding="utf-8") as gf: 166 | lines = gf.readlines() 167 | return PathSpec.from_lines("gitwildmatch", lines) 168 | 169 | 170 | @lru_cache 171 | def jupyter_dependencies_are_installed(*, verbose, quiet): 172 | try: 173 | import IPython # noqa:F401 174 | import tokenize_rt # noqa:F401 175 | except ModuleNotFoundError: 176 | if verbose or not quiet: 177 | msg = ( 178 | "Skipping .ipynb files as Jupyter dependencies are not installed.\n" 179 | "You can fix this by running ``pip install black[jupyter]``" 180 | ) 181 | print(msg) 182 | return False 183 | else: 184 | return True 185 | 186 | 187 | def gen_python_files( 188 | paths, 189 | root, 190 | include, 191 | exclude, 192 | extend_exclude, 193 | force_exclude, 194 | report, 195 | gitignore, 196 | *, 197 | verbose, 198 | quiet, 199 | ): 200 | """Generate all files under `path` whose paths are not excluded by the 201 | `exclude_regex` or `force_exclude` regexes, but are included by the `include` regex. 202 | 203 | Symbolic links pointing outside of the `root` directory are ignored. 204 | 205 | `report` is where output about exclusions goes. 206 | """ 207 | assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}" 208 | for child in paths: 209 | normalized_path = normalize_path_maybe_ignore(child, root, report) 210 | if normalized_path is None: 211 | continue 212 | 213 | # First ignore files matching .gitignore, if passed 214 | if gitignore is not None and gitignore.match_file(normalized_path): 215 | report.path_ignored(child, "matches the .gitignore file content") 216 | continue 217 | 218 | # Then ignore with `--exclude` `--exent-exclude` and `--force-exclude` options. 219 | normalized_path = "/" + normalized_path 220 | if child.is_dir(): 221 | normalized_path += "/" 222 | 223 | if path_is_excluded(normalized_path, exclude): 224 | report.path_ignored(child, "matches the --exclude regular expression") 225 | continue 226 | 227 | if path_is_excluded(normalized_path, extend_exclude): 228 | report.path_ignored( 229 | child, "matches the --extend-exclude regular expression" 230 | ) 231 | continue 232 | 233 | if path_is_excluded(normalized_path, force_exclude): 234 | report.path_ignored(child, "matches the --force-exclude regular expression") 235 | continue 236 | 237 | if child.is_dir(): 238 | # If gitignore is None, gitignore usage is disabled, while a Falsey 239 | # gitignore is when the directory doesn't have a .gitignore file. 240 | yield from gen_python_files( 241 | child.iterdir(), 242 | root, 243 | include, 244 | exclude, 245 | extend_exclude, 246 | force_exclude, 247 | report, 248 | gitignore + get_gitignore(child) if gitignore is not None else None, 249 | verbose=verbose, 250 | quiet=quiet, 251 | ) 252 | 253 | elif child.is_file(): 254 | if child.suffix == ".ipynb" and not jupyter_dependencies_are_installed( 255 | verbose=verbose, quiet=quiet 256 | ): 257 | continue 258 | include_match = include.search(normalized_path) if include else True 259 | if include_match: 260 | yield child 261 | -------------------------------------------------------------------------------- /blackdoc/blacken.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import re 3 | 4 | import black 5 | from blib2to3.pgen2.tokenize import TokenError 6 | 7 | from blackdoc.formats import extract_code, reformat_code 8 | 9 | 10 | def parse_message(message): 11 | line_re = re.compile( 12 | r"^(?P[^:]+): (?P\d+):" 13 | r"(?P\d+): (?P.+)$" 14 | ) 15 | 16 | types = { 17 | "message": str, 18 | "line_number": int, 19 | "column_number": int, 20 | "faulty_line": str, 21 | } 22 | 23 | match = line_re.match(message) 24 | if match is None: 25 | raise ValueError(f"invalid error message: {message}") 26 | 27 | return tuple(types[key](value) for key, value in match.groupdict().items()) 28 | 29 | 30 | def blacken(lines, mode=None): 31 | for original_line_range, code_format, line_unit in lines: 32 | if code_format == "none": 33 | yield line_unit 34 | continue 35 | 36 | indentation_depth, parameters, code = extract_code(line_unit, code_format) 37 | 38 | current_mode = black.FileMode() if mode is None else copy.copy(mode) 39 | current_mode.line_length -= indentation_depth + parameters.pop( 40 | "prompt_length", 0 41 | ) 42 | 43 | original_line_number, _ = original_line_range 44 | original_line_number += parameters.pop("n_header_lines", 0) 45 | 46 | try: 47 | blackened = black.format_str(code, mode=current_mode).rstrip() 48 | except TokenError as e: 49 | message, (apparent_line_number, column) = e.args 50 | 51 | lineno = original_line_number + (apparent_line_number - 1) 52 | faulty_line = code.split("\n")[(apparent_line_number - 1) - 1] 53 | 54 | raise black.InvalidInput( 55 | f"Cannot parse: {lineno}:{column}: {message}: {faulty_line}" 56 | ) 57 | except black.InvalidInput as e: 58 | message, apparent_line_number, column, faulty_line = parse_message(str(e)) 59 | 60 | lineno = original_line_number + (apparent_line_number - 1) 61 | raise black.InvalidInput(f"{message}: {lineno}:{column}: {faulty_line}") 62 | except IndentationError as e: 63 | lineno = original_line_number + (e.lineno - 1) 64 | line = e.text.rstrip() 65 | 66 | # TODO: try to find the actual line, this exception is 67 | # only raised when the indentation causes the code to 68 | # become ambiguous 69 | raise black.InvalidInput(f"Invalid indentation: {lineno}: {line}") 70 | 71 | reformatted = reformat_code( 72 | blackened, code_format, indentation_depth, **parameters 73 | ) 74 | 75 | yield reformatted 76 | -------------------------------------------------------------------------------- /blackdoc/classification.py: -------------------------------------------------------------------------------- 1 | import more_itertools 2 | 3 | from blackdoc.formats import detection_funcs 4 | 5 | 6 | def detect_format(lines): 7 | lines = more_itertools.peekable(lines) 8 | while lines: 9 | maybe_detected = ( 10 | (name, func(lines)) 11 | for name, func in detection_funcs.items() 12 | if name != "none" 13 | ) 14 | detected = {name: value for name, value in maybe_detected if value is not None} 15 | 16 | if not detected: 17 | yield detection_funcs["none"](lines) 18 | elif len(detected) > 1: 19 | formatted_match_names = ", ".join(sorted(detected.keys())) 20 | raise RuntimeError( 21 | "cannot detect code format for line:" 22 | f" it is claimed by {formatted_match_names}: {lines.peek()}" 23 | ) 24 | else: 25 | yield more_itertools.one(detected.values()) 26 | -------------------------------------------------------------------------------- /blackdoc/colors.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import re 3 | 4 | from rich.highlighter import Highlighter 5 | 6 | line_re = re.compile("\n") 7 | trailing_whitespace_re = re.compile(r"\s+$") 8 | 9 | 10 | def line_style(lineno, line): 11 | if line.startswith("+++") or line.startswith("---"): 12 | yield lineno, (0, len(line)), "bold" 13 | elif line.startswith("@@"): 14 | yield lineno, (0, len(line)), "cyan" 15 | elif line.startswith("+"): 16 | yield lineno, (0, len(line)), "green" 17 | elif line.startswith("-"): 18 | yield lineno, (0, len(line.rstrip())), "red" 19 | trailing_whitespace = trailing_whitespace_re.search(line) 20 | if trailing_whitespace: 21 | start, end = trailing_whitespace.span() 22 | yield lineno, (start, end), "red on red" 23 | else: 24 | yield lineno, (0, len(line)), "" 25 | 26 | 27 | def line_offsets(text): 28 | matches = line_re.finditer(text) 29 | 30 | return [0] + [m.end() for m in matches] 31 | 32 | 33 | def move_span(start, end, offset): 34 | return start + offset, end + offset 35 | 36 | 37 | class DiffHighlighter(Highlighter): 38 | def highlight(self, text): 39 | def diff_styles(text): 40 | lines = text.split("\n") 41 | line_styles = itertools.chain.from_iterable( 42 | line_style(lineno, line) for lineno, line in enumerate(lines, start=1) 43 | ) 44 | 45 | offsets = line_offsets(text) 46 | styles = { 47 | move_span(start, end, offsets[lineno - 1]): style 48 | for lineno, (start, end), style in line_styles 49 | } 50 | 51 | yield from styles.items() 52 | 53 | for (start, end), style in diff_styles(text.plain): 54 | text.stylize(style, start=start, end=end) 55 | 56 | 57 | class FileHighlighter(Highlighter): 58 | highlights = { 59 | r"[0-9]+ files?(?!.*fail)": "blue", 60 | r"^.+reformatted$": "bold", 61 | r"^.+fail.+$": "red", 62 | } 63 | 64 | def highlight(self, text): 65 | for highlight_re, style in self.highlights.items(): 66 | text.highlight_regex(highlight_re, style=style) 67 | -------------------------------------------------------------------------------- /blackdoc/console.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | 3 | out = Console() 4 | err = Console(stderr=True) 5 | -------------------------------------------------------------------------------- /blackdoc/diff.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import difflib 3 | 4 | 5 | def unified_diff(a, b, path): 6 | then = datetime.datetime.utcfromtimestamp(path.stat().st_mtime) 7 | now = datetime.datetime.utcnow() 8 | src_name = f"{path}\t{then} +0000" 9 | dst_name = f"{path}\t{now} +0000" 10 | 11 | diff = "\n".join( 12 | difflib.unified_diff( 13 | a.splitlines(), 14 | b.splitlines(), 15 | fromfile=src_name, 16 | tofile=dst_name, 17 | lineterm="", 18 | ) 19 | ) 20 | 21 | return diff 22 | -------------------------------------------------------------------------------- /blackdoc/files.py: -------------------------------------------------------------------------------- 1 | from black import Report 2 | 3 | from blackdoc.blackcompat import ( 4 | find_project_root, 5 | gen_python_files, 6 | get_gitignore, 7 | normalize_path_maybe_ignore, 8 | ) 9 | from blackdoc.console import err 10 | 11 | 12 | def collect_files(src, include, exclude, extend_exclude, force_exclude, quiet, verbose): 13 | root, _ = find_project_root(tuple(src)) 14 | gitignore = get_gitignore(root) 15 | report = Report() 16 | 17 | for path in src: 18 | if path.is_dir(): 19 | yield from gen_python_files( 20 | path.iterdir(), 21 | root, 22 | include, 23 | exclude, 24 | extend_exclude, 25 | force_exclude, 26 | report, 27 | gitignore, 28 | quiet=quiet, 29 | verbose=verbose, 30 | ) 31 | elif str(path) == "-": 32 | yield path 33 | elif path.is_file(): 34 | normalized_path = normalize_path_maybe_ignore(path, root, report) 35 | if normalized_path is None: 36 | continue 37 | 38 | normalized_path = "/" + normalized_path 39 | # Hard-exclude any files that matches the `--force-exclude` regex. 40 | if force_exclude: 41 | force_exclude_match = force_exclude.search(normalized_path) 42 | else: 43 | force_exclude_match = None 44 | if force_exclude_match and force_exclude_match.group(0): 45 | report.path_ignored( 46 | path, "matches the --force-exclude regular expression" 47 | ) 48 | continue 49 | 50 | yield path 51 | else: 52 | err.print(f"invalid path: {path}", style="red") 53 | -------------------------------------------------------------------------------- /blackdoc/formats/__init__.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import more_itertools 4 | 5 | from blackdoc.formats import doctest, ipython, none, rst 6 | from blackdoc.formats.errors import InvalidFormatError # noqa: F401 7 | from blackdoc.formats.register import ( 8 | detection_funcs, # noqa: F401 9 | disable, # noqa: F401 10 | extraction_funcs, 11 | format_include_patterns, # noqa: F401 12 | include_patterns, # noqa: F401 13 | reformatting_funcs, 14 | register_format, 15 | ) 16 | 17 | 18 | def extract_code(line_unit, code_format): 19 | dedented = textwrap.dedent(line_unit) 20 | indentation_depth = len(more_itertools.first(line_unit.split("\n"))) - len( 21 | more_itertools.first(dedented.split("\n")) 22 | ) 23 | 24 | func = extraction_funcs.get(code_format, None) 25 | if func is None: 26 | raise RuntimeError(f"unknown code format: {code_format}") 27 | 28 | parameters, extracted = func(dedented) 29 | return indentation_depth, parameters, extracted 30 | 31 | 32 | def reformat_code(line_unit, code_format, indentation_depth, **parameters): 33 | func = reformatting_funcs.get(code_format, None) 34 | if func is None: 35 | raise RuntimeError(f"unknown code format: {code_format}") 36 | 37 | reformatted = func(line_unit, **parameters) 38 | 39 | return textwrap.indent(reformatted, " " * indentation_depth) 40 | 41 | 42 | for module in (none, doctest, ipython, rst): 43 | register_format(module.name, module) 44 | -------------------------------------------------------------------------------- /blackdoc/formats/doctest.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import io 3 | import itertools 4 | import re 5 | import sys 6 | import tokenize 7 | from tokenize import TokenError 8 | 9 | import more_itertools 10 | 11 | from blackdoc.formats.errors import InvalidFormatError 12 | 13 | name = "doctest" 14 | prompt_length = 4 15 | prompt = ">>>" 16 | prompt_re = re.compile(r"(>>> ?)") 17 | continuation_prompt = "..." 18 | continuation_prompt_re = re.compile(r"(\.\.\. ?)") 19 | include_pattern = r"\.pyi?$" 20 | block_start_re = re.compile(r"^[^#:]+:(\s*#.*)?$") 21 | 22 | 23 | def continuation_lines(lines): 24 | while True: 25 | try: 26 | line_number, line = lines.peek() 27 | except StopIteration: 28 | line_number = -1 29 | line = "" 30 | 31 | if not continuation_prompt_re.match(line.lstrip()): 32 | break 33 | 34 | # actually consume the item 35 | more_itertools.consume(lines, n=1) 36 | 37 | yield line_number, line 38 | 39 | 40 | def detection_func(lines): 41 | try: 42 | _, line = lines.peek() 43 | except StopIteration: 44 | line = "" 45 | 46 | if not prompt_re.match(line.lstrip()): 47 | return None 48 | 49 | detected_lines = list( 50 | itertools.chain([more_itertools.first(lines)], continuation_lines(lines)) 51 | ) 52 | line_numbers, lines = map(tuple, more_itertools.unzip(detected_lines)) 53 | 54 | line_range = min(line_numbers), max(line_numbers) + 1 55 | if line_numbers != tuple(range(line_range[0], line_range[1])): 56 | raise InvalidFormatError("line numbers are not contiguous") 57 | 58 | return line_range, name, "\n".join(lines) 59 | 60 | 61 | def suppress(iterable, errors): 62 | iter_ = iter(iterable) 63 | while True: 64 | try: 65 | yield next(iter_) 66 | except errors: 67 | yield None 68 | except StopIteration: 69 | break 70 | 71 | 72 | def tokenize_string(code): 73 | readline = io.StringIO(code).readline 74 | 75 | return tokenize.generate_tokens(readline) 76 | 77 | 78 | def extract_string_tokens(code): 79 | tokens = tokenize_string(code) 80 | 81 | # suppress invalid code errors: `black` will raise with a better error message 82 | return ( 83 | token 84 | for token in suppress(tokens, TokenError) 85 | if token is not None and token.type == tokenize.STRING 86 | ) 87 | 88 | 89 | def detect_docstring_quotes(code_unit): 90 | def extract_quotes(string): 91 | if string.startswith("'''") and string.endswith("'''"): 92 | return "'''" 93 | elif string.startswith('"""') and string.endswith('"""'): 94 | return '"""' 95 | else: 96 | return None 97 | 98 | string_tokens = list(extract_string_tokens(code_unit)) 99 | token_quotes = {token: extract_quotes(token.string) for token in string_tokens} 100 | quotes = (quote for quote in token_quotes.values() if quote is not None) 101 | 102 | return more_itertools.first(quotes, None) 103 | 104 | 105 | def extraction_func(line): 106 | def extract_prompt(line): 107 | match = prompt_re.match(line) 108 | if match is not None: 109 | (prompt,) = match.groups() 110 | return prompt 111 | 112 | match = continuation_prompt_re.match(line) 113 | if match is not None: 114 | (prompt,) = match.groups() 115 | return prompt 116 | 117 | return "" 118 | 119 | def remove_prompt(line): 120 | prompt = extract_prompt(line) 121 | return line[len(prompt) :] 122 | 123 | lines = line.split("\n") 124 | if any( 125 | extract_prompt(line).rstrip() not in (prompt, continuation_prompt) 126 | for line in lines 127 | ): 128 | raise InvalidFormatError(f"misformatted code unit: {line}") 129 | 130 | extracted_line = "\n".join(remove_prompt(line) for line in lines) 131 | docstring_quotes = detect_docstring_quotes(extracted_line) 132 | 133 | return { 134 | "prompt_length": len(prompt) + 1, 135 | "docstring_quotes": docstring_quotes, 136 | }, extracted_line 137 | 138 | 139 | def restore_quotes(code_unit, original_quotes): 140 | def line_offsets(code_unit): 141 | offsets = [m.end() for m in re.finditer("\n", code_unit)] 142 | 143 | return {lineno: offset for lineno, offset in enumerate([0] + offsets, start=1)} 144 | 145 | def compute_offset(pos, offsets): 146 | lineno, charno = pos 147 | return offsets[lineno] + charno 148 | 149 | if original_quotes is None: 150 | return code_unit 151 | 152 | to_replace = "'''" if original_quotes == '"""' else '"""' 153 | 154 | string_tokens = extract_string_tokens(code_unit) 155 | triple_quote_tokens = [ 156 | token 157 | for token in string_tokens 158 | if token.string.startswith(to_replace) and token.string.endswith(to_replace) 159 | ] 160 | 161 | offsets = line_offsets(code_unit) 162 | mutable_string = io.StringIO(code_unit) 163 | for token in triple_quote_tokens: 164 | # find the offset in the stream 165 | start = compute_offset(token.start, offsets) 166 | end = compute_offset(token.end, offsets) - 3 167 | 168 | mutable_string.seek(start) 169 | mutable_string.write(original_quotes) 170 | 171 | mutable_string.seek(end) 172 | mutable_string.write(original_quotes) 173 | 174 | restored_code_unit = mutable_string.getvalue() 175 | 176 | return restored_code_unit 177 | 178 | 179 | def split_by_statement(code_unit): 180 | """split a code unit into individual statements 181 | 182 | At this point, the only way to have more than a single statement 183 | is by joining multiple (non-block) statements with a `;`. 184 | """ 185 | 186 | def lineno(node): 187 | # TODO: remove once we drop support for python=3.7 188 | version = (sys.version_info.major, sys.version_info.minor) 189 | 190 | if ( 191 | version < (3, 8) 192 | and isinstance(node, ast.Expr) 193 | and isinstance(node.value, ast.Str) 194 | ): 195 | # bug in ast (fixed in py38): lineno is wrong for multi-line string expressions 196 | # https://bugs.python.org/issue16806 197 | n_lines = len(node.value.s.split("\n")) 198 | lineno = node.lineno - n_lines + 1 199 | elif isinstance(node, (ast.FunctionDef, ast.ClassDef)): 200 | linenos = [node.lineno] + [dec.lineno for dec in node.decorator_list] 201 | lineno = min(linenos) 202 | else: 203 | lineno = node.lineno 204 | 205 | return lineno 206 | 207 | content = ast.parse(code_unit).body 208 | 209 | lines = code_unit.split("\n") 210 | if not content: 211 | return [lines] 212 | 213 | indices = [lineno(obj) - 1 for obj in content] 214 | # make sure comments are included 215 | indices[0] = 0 216 | slices = more_itertools.zip_offset(indices, indices, offsets=(0, 1), longest=True) 217 | return [lines[start:stop] for start, stop in slices] 218 | 219 | 220 | def reformatting_func(code_unit, docstring_quotes): 221 | def is_comment(line): 222 | return line.lstrip().startswith("#") 223 | 224 | def is_decorator(line): 225 | return line.lstrip().startswith("@") 226 | 227 | def drop_while(iterable, predicate): 228 | peekable = more_itertools.peekable(iterable) 229 | while True: 230 | try: 231 | current = peekable.peek() 232 | except StopIteration: 233 | break 234 | 235 | if not predicate(current): 236 | break 237 | 238 | more_itertools.consume(peekable, n=1) 239 | 240 | yield from peekable 241 | 242 | def is_block(lines): 243 | block_lines = drop_while( 244 | lines, lambda line: is_comment(line) or is_decorator(line) 245 | ) 246 | first_line = more_itertools.first(block_lines, default="") 247 | match = block_start_re.match(first_line) 248 | return match is not None 249 | 250 | def add_prompt(prompt, line): 251 | if not line: 252 | return prompt 253 | 254 | return " ".join([prompt, line]) 255 | 256 | def reformat_code_unit(lines): 257 | if is_block(lines): 258 | lines.append("") 259 | 260 | lines_ = iter(lines) 261 | return itertools.chain( 262 | more_itertools.always_iterable( 263 | add_prompt(prompt, more_itertools.first(lines_)) 264 | ), 265 | (add_prompt(continuation_prompt, line) for line in lines_), 266 | ) 267 | 268 | restored_quotes = restore_quotes(code_unit.rstrip(), docstring_quotes) 269 | 270 | subunits = split_by_statement(restored_quotes) 271 | 272 | return "\n".join( 273 | itertools.chain.from_iterable(reformat_code_unit(unit) for unit in subunits) 274 | ) 275 | -------------------------------------------------------------------------------- /blackdoc/formats/errors.py: -------------------------------------------------------------------------------- 1 | class InvalidFormatError(ValueError): 2 | pass 3 | -------------------------------------------------------------------------------- /blackdoc/formats/ipython.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import re 3 | 4 | import more_itertools 5 | 6 | from blackdoc.formats.errors import InvalidFormatError 7 | 8 | name = "ipython" 9 | 10 | prompt_re = re.compile(r"^(?P[ ]*)(?PIn \[(?P\d+)\]: )") 11 | continuation_prompt_re = re.compile(r"^(?P[ ]*)\.\.\.: ") 12 | 13 | prompt_template = "In [{count}]: " 14 | continuation_template = "...: " 15 | 16 | magic_re = re.compile(r"^(!.*|%.*|@[a-zA-Z_][a-zA-Z0-9_]* .+)") 17 | magic_comment = "" 18 | 19 | include_pattern = r"\.pyi?$" 20 | 21 | 22 | def continuation_lines(lines, indent, prompt_length): 23 | while True: 24 | try: 25 | line_number, line = lines.peek() 26 | except StopIteration: 27 | line_number = -1 28 | line = "" 29 | 30 | match = continuation_prompt_re.match(line) 31 | if not match or len(match.groupdict()["indent"]) - prompt_length + 5 != indent: 32 | break 33 | 34 | # actually consume the item 35 | more_itertools.consume(lines, n=1) 36 | 37 | yield line_number, line 38 | 39 | 40 | def detection_func(lines): 41 | try: 42 | _, line = lines.peek() 43 | except StopIteration: 44 | line = "" 45 | 46 | match = prompt_re.match(line) 47 | if not match: 48 | return None 49 | 50 | groups = match.groupdict() 51 | indent = len(groups["indent"]) 52 | prompt_length = len(groups["prompt"]) 53 | 54 | detected_lines = list( 55 | itertools.chain( 56 | [more_itertools.first(lines)], 57 | continuation_lines(lines, indent, prompt_length), 58 | ) 59 | ) 60 | line_numbers, lines = map(tuple, more_itertools.unzip(detected_lines)) 61 | 62 | line_range = min(line_numbers), max(line_numbers) + 1 63 | if line_numbers != tuple(range(line_range[0], line_range[1])): 64 | raise InvalidFormatError("line numbers are not contiguous") 65 | 66 | return line_range, name, "\n".join(lines) 67 | 68 | 69 | def is_ipython(line): 70 | is_prompt = prompt_re.match(line) 71 | is_continuation_prompt = continuation_prompt_re.match(line) 72 | return is_prompt or is_continuation_prompt 73 | 74 | 75 | def metadata(line): 76 | match = prompt_re.match(line) 77 | if not match: 78 | return {} 79 | 80 | groups = match.groupdict() 81 | return {"count": int(groups["count"])} 82 | 83 | 84 | def hide_magic(code): 85 | def comment_magic(line): 86 | stripped = line.lstrip() 87 | indent = len(line) - len(stripped) 88 | 89 | if not stripped or not magic_re.match(stripped): 90 | return line 91 | 92 | return " " * indent + f"# {magic_comment}" + stripped 93 | 94 | lines = code.split("\n") 95 | processed = tuple(comment_magic(line) for line in lines) 96 | 97 | return "\n".join(processed) 98 | 99 | 100 | def reveal_magic(code): 101 | def uncomment_magic(line): 102 | stripped = line.lstrip() 103 | 104 | if magic_comment not in line: 105 | return line 106 | 107 | indent = len(line) - len(stripped) 108 | return " " * indent + stripped[len(magic_comment) + 2 :] 109 | 110 | lines = code.split("\n") 111 | processed = tuple(uncomment_magic(line) for line in lines) 112 | 113 | return "\n".join(processed) 114 | 115 | 116 | def extraction_func(code): 117 | def remove_prompt(line, count): 118 | n = len(prompt_template.format(count=count)) 119 | return line[n:] 120 | 121 | lines = code.split("\n") 122 | if len(lines) == 0: 123 | raise InvalidFormatError("no lines found") 124 | 125 | parameters = metadata(lines[0]) 126 | 127 | if not all(is_ipython(line) for line in lines): 128 | raise InvalidFormatError(f"misformatted code unit: {code}") 129 | 130 | extracted = "\n".join(remove_prompt(line, **parameters) for line in lines) 131 | 132 | return parameters, hide_magic(extracted) 133 | 134 | 135 | def reformatting_func(line, count): 136 | prompt = prompt_template.format(count=count) 137 | continuation_prompt = ( 138 | " " * (len(prompt) - len(continuation_template)) + continuation_template 139 | ) 140 | 141 | lines = iter(reveal_magic(line).split("\n")) 142 | 143 | reformatted = "\n".join( 144 | itertools.chain( 145 | more_itertools.always_iterable(prompt + more_itertools.first(lines)), 146 | (continuation_prompt + line for line in lines), 147 | ) 148 | ) 149 | 150 | return reformatted 151 | -------------------------------------------------------------------------------- /blackdoc/formats/none.py: -------------------------------------------------------------------------------- 1 | import more_itertools 2 | 3 | name = "none" 4 | 5 | 6 | def detection_func(lines): 7 | number, line = more_itertools.first(lines) 8 | return (number, number + 1), name, line 9 | 10 | 11 | def extraction_func(line): 12 | return {}, line 13 | 14 | 15 | def reformatting_func(line): 16 | return line 17 | -------------------------------------------------------------------------------- /blackdoc/formats/register.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import more_itertools 4 | 5 | detection_funcs = {} 6 | extraction_funcs = {} 7 | reformatting_funcs = {} 8 | include_patterns = {} 9 | 10 | 11 | def format_include_patterns(): 12 | patterns = set(include_patterns.values()) 13 | joined_patterns = "|".join(patterns) 14 | 15 | if "|" not in joined_patterns: 16 | return joined_patterns 17 | else: 18 | return f"({joined_patterns})" 19 | 20 | 21 | def disable(format_names): 22 | names = tuple(more_itertools.always_iterable(format_names)) 23 | unknown_names = tuple(name for name in names if name not in detection_funcs) 24 | if any(unknown_names): 25 | raise ValueError(f"unknown formats: {','.join(unknown_names)}") 26 | 27 | for name in names: 28 | del detection_funcs[name] 29 | 30 | 31 | def register_format(name, obj): 32 | """register a new format""" 33 | if name in detection_funcs: 34 | warnings.warn(f"{name} already registered", RuntimeWarning) 35 | 36 | detection_func = getattr(obj, "detection_func") 37 | extraction_func = getattr(obj, "extraction_func") 38 | reformatting_func = getattr(obj, "reformatting_func") 39 | include_pattern = getattr(obj, "include_pattern", None) 40 | 41 | detection_funcs[name] = detection_func 42 | extraction_funcs[name] = extraction_func 43 | reformatting_funcs[name] = reformatting_func 44 | 45 | if include_pattern is not None: 46 | include_patterns[name] = include_pattern 47 | -------------------------------------------------------------------------------- /blackdoc/formats/rst.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import re 3 | import textwrap 4 | 5 | import more_itertools 6 | 7 | from blackdoc.formats.doctest import prompt_re as doctest_prompt_re 8 | from blackdoc.formats.errors import InvalidFormatError 9 | from blackdoc.formats.ipython import hide_magic, reveal_magic 10 | from blackdoc.formats.ipython import prompt_re as ipython_prompt_re 11 | 12 | name = "rst" 13 | 14 | directive_re = re.compile( 15 | "(?P[ ]*).. (?P[a-z][-a-z]*)::(?: (?P[a-z]+))?" 16 | ) 17 | option_re = re.compile(r"^\s*:[^:]+:") 18 | 19 | include_pattern = r"\.rst$" 20 | 21 | 22 | def has_prompt(line): 23 | return any( 24 | prompt_re.match(line.lstrip()) 25 | for prompt_re in [ipython_prompt_re, doctest_prompt_re] 26 | ) 27 | 28 | 29 | def take_while(iterable, predicate): 30 | while True: 31 | try: 32 | taken = next(iterable) 33 | except StopIteration: 34 | break 35 | 36 | if not predicate(taken): 37 | iterable.prepend(taken) 38 | break 39 | 40 | yield taken 41 | 42 | 43 | def continuation_lines(lines, indent): 44 | options = tuple(take_while(lines, lambda x: option_re.match(x[1]))) 45 | newlines = tuple(take_while(lines, lambda x: not x[1].strip())) 46 | decorator_lines = tuple(take_while(lines, lambda x: x[1].lstrip().startswith("@"))) 47 | _, next_line = lines.peek((0, None)) 48 | if next_line is None: 49 | return 50 | 51 | if has_prompt(next_line): 52 | lines.prepend(*options, *newlines, *decorator_lines) 53 | raise RuntimeError("prompt detected") 54 | 55 | yield from options 56 | yield from newlines 57 | yield from decorator_lines 58 | 59 | while True: 60 | newlines = tuple(take_while(lines, lambda x: not x[1].strip())) 61 | try: 62 | line_number, line = lines.peek() 63 | except StopIteration: 64 | lines.prepend(*newlines) 65 | break 66 | 67 | current_indent = len(line) - len(line.lstrip()) 68 | if current_indent <= indent: 69 | # put back the newlines, if any 70 | lines.prepend(*newlines) 71 | break 72 | 73 | yield from newlines 74 | 75 | # consume the line 76 | more_itertools.consume(lines, n=1) 77 | 78 | yield line_number, line 79 | 80 | 81 | def detection_func(lines): 82 | try: 83 | line_number, line = lines.peek() 84 | except StopIteration: 85 | return None 86 | 87 | match = directive_re.match(line) 88 | if not match: 89 | return None 90 | 91 | directive = match.groupdict() 92 | 93 | if directive["name"] not in ( 94 | "code", 95 | "code-block", 96 | "ipython", 97 | "testcode", 98 | "testsetup", 99 | "testcleanup", 100 | ): 101 | return None 102 | 103 | if directive["language"] not in ("python", None): 104 | return None 105 | 106 | indent = len(directive.pop("indent")) 107 | 108 | try: 109 | detected_lines = list( 110 | itertools.chain( 111 | [more_itertools.first(lines)], 112 | continuation_lines(lines, indent), 113 | ) 114 | ) 115 | except RuntimeError as e: 116 | if str(e) != "prompt detected": 117 | raise 118 | 119 | lines.prepend((line_number, line)) 120 | return None 121 | 122 | line_numbers, lines = map(tuple, more_itertools.unzip(detected_lines)) 123 | line_range = min(line_numbers), max(line_numbers) + 1 124 | if line_numbers != tuple(range(line_range[0], line_range[1])): 125 | raise RuntimeError("line numbers are not contiguous") 126 | 127 | return line_range, name, "\n".join(lines) 128 | 129 | 130 | def extraction_func(code): 131 | lines = more_itertools.peekable(iter(code.split("\n"))) 132 | 133 | match = directive_re.fullmatch(more_itertools.first(lines)) 134 | if not match: 135 | raise InvalidFormatError(f"misformatted code block:\n{code}") 136 | 137 | directive = match.groupdict() 138 | directive.pop("indent") 139 | 140 | directive["options"] = tuple( 141 | line.strip() for line in take_while(lines, lambda line: option_re.match(line)) 142 | ) 143 | 144 | # correct a missing newline 145 | newline = lines.peek(None) 146 | if newline is None: 147 | raise InvalidFormatError( 148 | "misformatted code block:" 149 | " newline after directive options required" 150 | " but found " 151 | ) 152 | elif not newline.strip(): 153 | more_itertools.first(lines) 154 | 155 | lines_ = tuple(lines) 156 | if len(lines_) == 0: 157 | raise InvalidFormatError("misformatted code block: could not find any code") 158 | 159 | indent = len(lines_[0]) - len(lines_[0].lstrip()) 160 | directive["prompt_length"] = indent 161 | directive["n_header_lines"] = len(directive["options"]) + 2 162 | 163 | code_ = hide_magic(textwrap.dedent("\n".join(lines_))) 164 | 165 | return directive, code_ 166 | 167 | 168 | def reformatting_func(code, name, language, options, prompt_length=4): 169 | indent = " " * prompt_length 170 | 171 | directive = " ".join( 172 | [f".. {name}::"] + ([language] if language is not None else []) 173 | ) 174 | 175 | options_ = textwrap.indent("\n".join(options), indent) if options else None 176 | code_ = textwrap.indent(reveal_magic(code), indent) 177 | 178 | return "\n".join( 179 | line for line in (directive, options_, "", code_) if line is not None 180 | ) 181 | -------------------------------------------------------------------------------- /blackdoc/report.py: -------------------------------------------------------------------------------- 1 | from rich.text import Text 2 | 3 | from blackdoc.colors import FileHighlighter 4 | 5 | highlighter = FileHighlighter() 6 | 7 | 8 | def statistics(sources): 9 | from collections import Counter 10 | 11 | statistics = Counter(sources.values()) 12 | 13 | n_unchanged = statistics.pop("unchanged", 0) 14 | n_reformatted = statistics.pop("reformatted", 0) 15 | n_error = statistics.pop("error", 0) 16 | 17 | if len(statistics) != 0: 18 | raise RuntimeError(f"unknown results: {statistics.keys()}") 19 | 20 | return n_reformatted, n_unchanged, n_error 21 | 22 | 23 | def noun(n): 24 | return "file" if n < 2 else "files" 25 | 26 | 27 | def _report_words(report_type, conditional): 28 | mapping = { 29 | "reformatted": { 30 | False: "reformatted", 31 | True: "would be reformatted", 32 | }, 33 | "unchanged": { 34 | False: "left unchanged", 35 | True: "would be left unchanged", 36 | }, 37 | "error": { 38 | False: "failed to reformat", 39 | True: "would fail to reformat", 40 | }, 41 | } 42 | 43 | return mapping.get(report_type, {}).get(conditional) 44 | 45 | 46 | class Report: 47 | def __init__(self, n_reformatted, n_unchanged, n_error, conditional=False): 48 | self.n_reformatted = n_reformatted 49 | self.n_unchanged = n_unchanged 50 | self.n_error = n_error 51 | 52 | self.conditional = conditional 53 | 54 | @classmethod 55 | def from_sources(cls, sources, conditional=False): 56 | n_reformatted, n_unchanged, n_error = statistics(sources) 57 | 58 | return cls(n_reformatted, n_unchanged, n_error, conditional=conditional) 59 | 60 | def __repr__(self): 61 | params = [ 62 | f"{name}={getattr(self, name)}" 63 | for name in ["n_reformatted", "n_unchanged", "n_error", "conditional"] 64 | ] 65 | return f"Report({', '.join(params)})" 66 | 67 | def _report_parts(self): 68 | report_types = ["reformatted", "unchanged", "error"] 69 | values = { 70 | report_type: getattr(self, f"n_{report_type}") 71 | for report_type in report_types 72 | } 73 | parts = [ 74 | f"{value} {noun(value)} {_report_words(report_type, self.conditional)}" 75 | for report_type, value in values.items() 76 | if value > 0 77 | ] 78 | return parts 79 | 80 | def __str__(self): 81 | parts = self._report_parts() 82 | return ", ".join(parts) + "." 83 | 84 | def __rich__(self): 85 | parts = [highlighter(part) for part in self._report_parts()] 86 | return Text(", ").join(parts) + Text(".") 87 | -------------------------------------------------------------------------------- /blackdoc/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keewis/blackdoc/278e9381ec20eda0acb7072bf54b989b1df7279a/blackdoc/tests/__init__.py -------------------------------------------------------------------------------- /blackdoc/tests/data/__init__.py: -------------------------------------------------------------------------------- 1 | from blackdoc.tests.data import doctest, ipython, rst # noqa: F401 2 | from blackdoc.tests.data.utils import ( # noqa: F401 3 | from_dict, 4 | print_classification, 5 | to_classification_format, 6 | ) 7 | -------------------------------------------------------------------------------- /blackdoc/tests/data/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import importlib 3 | import itertools 4 | import sys 5 | 6 | from blackdoc.tests.data.utils import format_classification 7 | 8 | 9 | def format_conflicting_ranges(index_a, index_b, ranges): 10 | range_a = ranges[index_a] 11 | range_b = ranges[index_b] 12 | return f"{range_a} (item {index_a + 1}) ←→ {range_b} (item {index_b + 1})" 13 | 14 | 15 | def display_data(lines, labels, ranges): 16 | labeled = tuple( 17 | ((start, stop), label, lines[start:stop]) 18 | for (start, stop), label in zip(ranges, labels) 19 | ) 20 | labeled_line_numbers = tuple( 21 | tuple(range(start, stop)) for (start, stop), _, _ in labeled 22 | ) 23 | combinations = itertools.combinations(enumerate(labeled_line_numbers), 2) 24 | intersections = { 25 | (index_a, index_b): set(first).intersection(second) 26 | for (index_a, first), (index_b, second) in combinations 27 | } 28 | faulty_ranges = tuple(key for key, value in intersections.items() if value) 29 | if faulty_ranges: 30 | formatted_errors = [ 31 | format_conflicting_ranges(index_a, index_b, ranges) 32 | for index_a, index_b in faulty_ranges 33 | ] 34 | sep = "\n -- " 35 | message = f"error: overlapping line ranges: {sep.join(formatted_errors)}" 36 | raise ValueError(message) 37 | 38 | covered_lines = tuple(itertools.chain.from_iterable(labeled_line_numbers)) 39 | missing_lines = tuple( 40 | index for index, _ in enumerate(lines) if index not in covered_lines 41 | ) 42 | missing_ranges = tuple((index, index + 1) for index in missing_lines) 43 | missing_label = "n/a" 44 | unlabeled = tuple( 45 | ((start, stop), missing_label, lines[start:stop]) 46 | for start, stop in missing_ranges 47 | ) 48 | combined = sorted( 49 | itertools.chain(labeled, unlabeled), 50 | key=lambda x: x[0][0], 51 | ) 52 | return format_classification( 53 | tuple((range_, name, "\n".join(unit)) for range_, name, unit in combined) 54 | ) 55 | 56 | 57 | def display_module(module): 58 | exit_status = 0 59 | line_length = 80 60 | 61 | top_rule = "━" * line_length 62 | bottom_rule = "━" * line_length 63 | mid_rule = "─" * line_length 64 | 65 | try: 66 | formatted = display_data(module.lines, module.line_labels, module.line_ranges) 67 | print(top_rule) 68 | print(f"{{:^{line_length}}}".format("test data")) 69 | print(mid_rule) 70 | print(formatted) 71 | print(bottom_rule) 72 | except ValueError as e: 73 | print(e) 74 | exit_status += 1 75 | 76 | try: 77 | formatted = display_data( 78 | module.expected_lines, 79 | module.expected_line_labels, 80 | module.expected_line_ranges, 81 | ) 82 | print("", "", sep="\n") 83 | print(top_rule) 84 | print(f"{{:^{line_length}s}}".format("expected output")) 85 | print(mid_rule) 86 | print(formatted) 87 | print(bottom_rule) 88 | except AttributeError: 89 | pass 90 | except ValueError as e: 91 | print(e) 92 | exit_status += 1 93 | 94 | return exit_status 95 | 96 | 97 | parser = argparse.ArgumentParser() 98 | parser.add_argument("format", help="print the data of this format") 99 | 100 | args = parser.parse_args() 101 | module = importlib.import_module(f".{args.format}", package="blackdoc.tests.data") 102 | sys.exit(display_module(module)) 103 | -------------------------------------------------------------------------------- /blackdoc/tests/data/doctest.py: -------------------------------------------------------------------------------- 1 | from blackdoc.tests.data.utils import from_dict 2 | 3 | docstring = """ a function to open files 4 | 5 | with a very long description 6 | 7 | >>> file = open( 8 | ... "very_long_filepath", 9 | ... mode="a", 10 | ... ) 11 | >>> file 12 | <_io.TextIOWrapper name='very_long_filepath' mode='w' encoding='UTF-8'> 13 | 14 | text after the first example, spanning 15 | multiple lines 16 | 17 | >>> file.closed 18 | False 19 | 20 | >>> ''' arbitrary triple-quoted string 21 | ... 22 | ... with a empty continuation line 23 | ... ''' 24 | >>> def myfunc2(arg1, arg2): 25 | ... pass 26 | >>> 27 | 28 | >>> if myfunc2(2, 1) is not None: 29 | ... print("caught") 30 | >>> a = 2 31 | ... 32 | >>> # this is not a block: 33 | """ 34 | lines = docstring.split("\n") 35 | labels = { 36 | 1: "none", 37 | 2: "none", 38 | 3: "none", 39 | 4: "none", 40 | (5, 9): "doctest", 41 | 9: "doctest", 42 | 10: "none", 43 | 11: "none", 44 | 12: "none", 45 | 13: "none", 46 | 14: "none", 47 | 15: "doctest", 48 | 16: "none", 49 | 17: "none", 50 | (18, 22): "doctest", 51 | (22, 24): "doctest", 52 | 24: "doctest", 53 | 25: "none", 54 | (26, 28): "doctest", 55 | (28, 30): "doctest", 56 | 30: "doctest", 57 | 31: "none", 58 | } 59 | line_ranges, line_labels = from_dict(labels) 60 | 61 | expected = """ a function to open files 62 | 63 | with a very long description 64 | 65 | >>> file = open( 66 | ... "very_long_filepath", 67 | ... mode="a", 68 | ... ) 69 | >>> file 70 | <_io.TextIOWrapper name='very_long_filepath' mode='w' encoding='UTF-8'> 71 | 72 | text after the first example, spanning 73 | multiple lines 74 | 75 | >>> file.closed 76 | False 77 | 78 | >>> ''' arbitrary triple-quoted string 79 | ... 80 | ... with a empty continuation line 81 | ... ''' 82 | >>> def myfunc2(arg1, arg2): 83 | ... pass 84 | ... 85 | >>> 86 | 87 | >>> if myfunc2(2, 1) is not None: 88 | ... print("caught") 89 | ... 90 | >>> a = 2 91 | >>> # this is not a block: 92 | """ 93 | expected_lines = expected.split("\n") 94 | expected_labels = { 95 | 1: "none", 96 | 2: "none", 97 | 3: "none", 98 | 4: "none", 99 | (5, 9): "doctest", 100 | 9: "doctest", 101 | 10: "none", 102 | 11: "none", 103 | 12: "none", 104 | 13: "none", 105 | 14: "none", 106 | 15: "doctest", 107 | 16: "none", 108 | 17: "none", 109 | (18, 22): "doctest", 110 | (22, 25): "doctest", 111 | 25: "doctest", 112 | 26: "none", 113 | (27, 30): "doctest", 114 | 30: "doctest", 115 | 31: "doctest", 116 | 32: "none", 117 | } 118 | expected_line_ranges, expected_line_labels = from_dict(expected_labels) 119 | -------------------------------------------------------------------------------- /blackdoc/tests/data/ipython.py: -------------------------------------------------------------------------------- 1 | from blackdoc.tests.data.utils import from_dict 2 | 3 | docstring = """ a function to open files 4 | 5 | with a very long description 6 | 7 | In [1]: file = open( 8 | ...: "very_long_filepath", 9 | ...: mode="a", 10 | ...: ) 11 | 12 | In [2]: file 13 | Out[2]: <_io.TextIOWrapper name='very_long_filepath' mode='w' encoding='UTF-8'> 14 | 15 | text after the first example, spanning 16 | multiple lines 17 | 18 | In [3]: file.closed 19 | Out[3]: False 20 | 21 | In [4]: %%time 22 | ...: file.close() 23 | 24 | In [5]: @savefig simple.png width=4in 25 | ...: @property 26 | ...: def my_property(self): 27 | ...: pass 28 | """ 29 | lines = docstring.split("\n") 30 | labels = { 31 | 1: "none", 32 | 2: "none", 33 | 3: "none", 34 | 4: "none", 35 | (5, 9): "ipython", 36 | 9: "none", 37 | 10: "ipython", 38 | 11: "none", 39 | 12: "none", 40 | 13: "none", 41 | 14: "none", 42 | 15: "none", 43 | 16: "ipython", 44 | 17: "none", 45 | 18: "none", 46 | (19, 21): "ipython", 47 | 21: "none", 48 | (22, 26): "ipython", 49 | 26: "none", 50 | } 51 | line_ranges, line_labels = from_dict(labels) 52 | -------------------------------------------------------------------------------- /blackdoc/tests/data/rst.py: -------------------------------------------------------------------------------- 1 | from blackdoc.tests.data.utils import from_dict 2 | 3 | content = """\ 4 | Long description of the function's assumptions and on how to call it. 5 | 6 | .. note:: 7 | 8 | this is not a code block 9 | 10 | As an example: 11 | 12 | .. code:: python 13 | :okwarning: 14 | 15 | file = open( 16 | "very_long_filepath", 17 | mode="a" 18 | ) 19 | 20 | 21 | .. code-block:: python 22 | 23 | with open( 24 | "very_long_filepath", 25 | mode="a" 26 | ) as f: 27 | content = f.read() 28 | 29 | A new example, this time with ipython: 30 | 31 | .. ipython:: 32 | 33 | %%time 34 | file = open( 35 | "very_long_filepath", 36 | mode="a" 37 | ) 38 | file 39 | 40 | with ipython prompts: 41 | 42 | .. ipython:: 43 | :okerror: 44 | 45 | In [1]: file = open( 46 | ...: "very_long_filepath", 47 | ...: mode="a" 48 | ...: ) 49 | 50 | In [2]: file 51 | 52 | In [3]: file.read_binary() 53 | 54 | with cell decorator: 55 | 56 | .. ipython:: 57 | :okerror: 58 | 59 | @verbatim 60 | In [1]: file = open( 61 | ...: "very_long_filepath", 62 | ...: mode="a" 63 | ...: ) 64 | 65 | In [2]: file 66 | 67 | In [3]: file.read_binary() 68 | 69 | a code block with a different language: 70 | 71 | .. code:: sh 72 | 73 | find . -name "*.py" 74 | 75 | a code block with testcode: 76 | 77 | .. testsetup:: 78 | 79 | file = open( 80 | "very_long_filepath", 81 | mode="a" 82 | ) 83 | 84 | .. testcode:: 85 | 86 | file 87 | 88 | .. testcleanup:: 89 | 90 | file.close() 91 | """ 92 | lines = content.split("\n") 93 | labels = { 94 | 1: "none", 95 | 2: "none", 96 | 3: "none", 97 | 4: "none", 98 | 5: "none", 99 | 6: "none", 100 | 7: "none", 101 | 8: "none", 102 | (9, 16): "rst", 103 | 16: "none", 104 | 17: "none", 105 | (18, 25): "rst", 106 | 25: "none", 107 | 26: "none", 108 | 27: "none", 109 | (28, 36): "rst", 110 | 36: "none", 111 | 37: "none", 112 | 38: "none", 113 | 39: "none", 114 | 40: "none", 115 | 41: "none", 116 | 42: "none", 117 | 43: "none", 118 | 44: "none", 119 | 45: "none", 120 | 46: "none", 121 | 47: "none", 122 | 48: "none", 123 | 49: "none", 124 | 50: "none", 125 | 51: "none", 126 | 52: "none", 127 | 53: "none", 128 | 54: "none", 129 | 55: "none", 130 | 56: "none", 131 | 57: "none", 132 | 58: "none", 133 | 59: "none", 134 | 60: "none", 135 | 61: "none", 136 | 62: "none", 137 | 63: "none", 138 | 64: "none", 139 | 65: "none", 140 | 66: "none", 141 | 67: "none", 142 | 68: "none", 143 | 69: "none", 144 | 70: "none", 145 | 71: "none", 146 | 72: "none", 147 | 73: "none", 148 | (74, 80): "rst", 149 | 80: "none", 150 | (81, 84): "rst", 151 | 84: "none", 152 | (85, 88): "rst", 153 | 88: "none", 154 | } 155 | line_ranges, line_labels = from_dict(labels) 156 | -------------------------------------------------------------------------------- /blackdoc/tests/data/utils.py: -------------------------------------------------------------------------------- 1 | def from_dict(labels): 2 | line_ranges = tuple( 3 | ( 4 | (lineno - 1, lineno) 5 | if not isinstance(lineno, tuple) 6 | else tuple(n - 1 for n in lineno) 7 | ) 8 | for lineno in labels.keys() 9 | ) 10 | line_labels = tuple(labels.values()) 11 | return line_ranges, line_labels 12 | 13 | 14 | def to_classification_format(labels, lines): 15 | prepared_labels = dict(zip(*from_dict(labels))) 16 | return tuple( 17 | ((min_ + 1, max_ + 1), label, "\n".join(lines[min_:max_])) 18 | for (min_, max_), label in prepared_labels.items() 19 | ) 20 | 21 | 22 | def format_line_with_range(name, range_, unit): 23 | min_, max_ = range_ 24 | line_numbers = range(min_, max_) 25 | 26 | no_group = " " 27 | start_group = "┐" 28 | mid_group = "│" 29 | end_group = "┘" 30 | 31 | lines = unit.split("\n") 32 | 33 | def determine_classifier(index): 34 | if max_ - min_ == 1: 35 | classifier = no_group 36 | elif index == 0: 37 | classifier = start_group 38 | elif index == max_ - min_ - 1: 39 | classifier = end_group 40 | else: 41 | classifier = mid_group 42 | 43 | return classifier 44 | 45 | return "\n".join( 46 | f"{name:>8s} {determine_classifier(index)} → {lineno:02d}: {line}" 47 | for index, (lineno, line) in enumerate(zip(line_numbers, lines)) 48 | ) 49 | 50 | 51 | def format_classification(labeled): 52 | return "\n".join( 53 | format_line_with_range(range, name, unit) for name, range, unit in labeled 54 | ) 55 | 56 | 57 | def print_classification(labeled): 58 | print(format_classification(labeled)) 59 | -------------------------------------------------------------------------------- /blackdoc/tests/test_autoupdate.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from blackdoc import autoupdate 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ["content", "expected"], 8 | ( 9 | pytest.param( 10 | """\ 11 | - repo: https://github.com/psf/black 12 | rev: 23.1.0 13 | hooks: 14 | - id: black 15 | """, 16 | "23.1.0", 17 | ), 18 | pytest.param( 19 | """\ 20 | - repo: https://github.com/psf/black 21 | rev: 22.7.1 22 | hooks: 23 | - id: black-jupyter 24 | """, 25 | "22.7.1", 26 | ), 27 | pytest.param( 28 | """\ 29 | - repo: https://github.com/psf/black-pre-commit-mirror 30 | rev: 23.10.1 31 | hooks: 32 | - id: black 33 | """, 34 | "23.10.1", 35 | ), 36 | pytest.param( 37 | """\ 38 | - repo: https://github.com/psf/black-pre-commit-mirror 39 | rev: 22.9.10 40 | hooks: 41 | - id: black-jupyter 42 | """, 43 | "22.9.10", 44 | ), 45 | pytest.param( 46 | """\ 47 | - repo: https://github.com/psf/black-pre-commit-mirror 48 | rev: 24.12.1 49 | hooks: 50 | - id: black 51 | - id: black-jupyter 52 | """, 53 | "24.12.1", 54 | ), 55 | pytest.param( 56 | """\ 57 | - repo: https://github.com/psf/black-pre-commit-mirror 58 | rev: 24.12.1 59 | hooks: 60 | - id: black-jupyter 61 | - id: black 62 | """, 63 | "24.12.1", 64 | ), 65 | ), 66 | ) 67 | def test_find_black_version(content, expected): 68 | version = autoupdate.find_black_version(content) 69 | 70 | assert version == expected 71 | 72 | 73 | @pytest.mark.parametrize( 74 | ["content", "version", "expected"], 75 | ( 76 | pytest.param( 77 | """\ 78 | - repo: https://github.com/pre-commit/pre-commit-hooks 79 | rev: 4.4.0 80 | hooks: 81 | - id: trailing-whitespace 82 | - id: end-of-file-fixer 83 | 84 | - repo: https://github.com/keewis/blackdoc 85 | rev: 3.8.0 86 | hooks: 87 | - id: blackdoc 88 | additional_dependencies: ["black==22.10.0"] 89 | - id: blackdoc-autoupdate-black 90 | """, 91 | "23.3.0", 92 | """\ 93 | - repo: https://github.com/pre-commit/pre-commit-hooks 94 | rev: 4.4.0 95 | hooks: 96 | - id: trailing-whitespace 97 | - id: end-of-file-fixer 98 | 99 | - repo: https://github.com/keewis/blackdoc 100 | rev: 3.8.0 101 | hooks: 102 | - id: blackdoc 103 | additional_dependencies: ["black==23.3.0"] 104 | - id: blackdoc-autoupdate-black 105 | """, 106 | ), 107 | pytest.param( 108 | """\ 109 | - repo: https://github.com/keewis/blackdoc 110 | rev: 3.8.0 111 | hooks: 112 | - id: blackdoc 113 | additional_dependencies: ["black==22.10.0"] 114 | - id: blackdoc-autoupdate-black 115 | """, 116 | "21.5.1", 117 | """\ 118 | - repo: https://github.com/keewis/blackdoc 119 | rev: 3.8.0 120 | hooks: 121 | - id: blackdoc 122 | additional_dependencies: ["black==21.5.1"] 123 | - id: blackdoc-autoupdate-black 124 | """, 125 | ), 126 | pytest.param( 127 | """\ 128 | - repo: https://github.com/keewis/blackdoc 129 | rev: 3.8.0 130 | hooks: 131 | - id: blackdoc 132 | additional_dependencies: ["black==22.10.0"] 133 | - id: blackdoc-autoupdate-black 134 | """, 135 | "23.7.12", 136 | """\ 137 | - repo: https://github.com/keewis/blackdoc 138 | rev: 3.8.0 139 | hooks: 140 | - id: blackdoc 141 | additional_dependencies: ["black==23.7.12"] 142 | - id: blackdoc-autoupdate-black 143 | """, 144 | ), 145 | ), 146 | ) 147 | def test_update_black_pin(content, version, expected): 148 | updated = autoupdate.update_black_pin(content, version) 149 | 150 | assert updated == expected 151 | 152 | 153 | @pytest.mark.parametrize( 154 | ["content", "expected"], 155 | ( 156 | pytest.param( 157 | """\ 158 | hooks: 159 | - repo: https://github.com/psf/black 160 | rev: 22.0.1 161 | hooks: 162 | - id: black 163 | 164 | - repo: https://github.com/keewis/blackdoc 165 | rev: 3.9.0 166 | hooks: 167 | - id: blackdoc 168 | additional_dependencies: ["black==22.0.1"] 169 | """, 170 | """\ 171 | hooks: 172 | - repo: https://github.com/psf/black 173 | rev: 22.0.1 174 | hooks: 175 | - id: black 176 | 177 | - repo: https://github.com/keewis/blackdoc 178 | rev: 3.9.0 179 | hooks: 180 | - id: blackdoc 181 | additional_dependencies: ["black==22.0.1"] 182 | """, 183 | ), 184 | pytest.param( 185 | """\ 186 | hooks: 187 | - repo: https://github.com/psf/black 188 | rev: 23.10.0 189 | hooks: 190 | - id: black 191 | 192 | - repo: https://github.com/keewis/blackdoc 193 | rev: 3.9.0 194 | hooks: 195 | - id: blackdoc 196 | additional_dependencies: ["black==22.0.1"] 197 | """, 198 | """\ 199 | hooks: 200 | - repo: https://github.com/psf/black 201 | rev: 23.10.0 202 | hooks: 203 | - id: black 204 | 205 | - repo: https://github.com/keewis/blackdoc 206 | rev: 3.9.0 207 | hooks: 208 | - id: blackdoc 209 | additional_dependencies: ["black==23.10.0"] 210 | """, 211 | ), 212 | ), 213 | ) 214 | def test_main(tmp_path, content, expected): 215 | path = tmp_path.joinpath(".pre-commit-config.yaml") 216 | path.write_text(content) 217 | 218 | stat = path.stat() 219 | 220 | return_value = autoupdate.main(path) 221 | 222 | updated = path.read_text() 223 | 224 | assert updated == expected 225 | if content == expected: 226 | assert path.stat() == stat 227 | assert return_value == 0 228 | else: 229 | assert return_value == 1 230 | -------------------------------------------------------------------------------- /blackdoc/tests/test_blacken.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from blackdoc.blacken import parse_message 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "message,expected", 8 | ( 9 | pytest.param( 10 | 'Cannot parse: 16:10: with new_open("abc) as f:', 11 | ("Cannot parse", 16, 10, 'with new_open("abc) as f:'), 12 | id="simple_message", 13 | ), 14 | ), 15 | ) 16 | def test_parse_message(message, expected): 17 | actual = parse_message(message) 18 | assert expected == actual 19 | -------------------------------------------------------------------------------- /blackdoc/tests/test_classification.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from blackdoc import classification, formats 4 | from blackdoc.tests import data 5 | from blackdoc.tests.data import print_classification, to_classification_format 6 | 7 | 8 | @pytest.mark.parametrize("format", ("rst", "doctest", "ipython")) 9 | def test_detect_format(format, monkeypatch): 10 | module = getattr(formats, format) 11 | detection_funcs = { 12 | format: module.detection_func, 13 | "none": formats.none.detection_func, 14 | } 15 | monkeypatch.setattr(classification, "detection_funcs", detection_funcs) 16 | 17 | data_module = getattr(data, format) 18 | expected = to_classification_format(data_module.labels, data_module.lines) 19 | lines = enumerate(data_module.lines, start=1) 20 | actual = tuple(classification.detect_format(lines)) 21 | 22 | print_classification(actual) 23 | 24 | assert expected == actual 25 | -------------------------------------------------------------------------------- /blackdoc/tests/test_colors.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import pytest 4 | from rich.text import Span 5 | 6 | from blackdoc import colors 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ["text", "spans"], 11 | ( 12 | pytest.param( 13 | textwrap.dedent( 14 | """\ 15 | - >>> a 16 | + >>> a + 1 17 | """.rstrip() 18 | ), 19 | [Span(0, 7, "red"), Span(8, 19, "green")], 20 | id="simple replacement", 21 | ), 22 | pytest.param( 23 | textwrap.dedent( 24 | f"""\ 25 | - >>> a{' ' * 5} 26 | + >>> a + 1 27 | """.rstrip() 28 | ), 29 | [Span(0, 7, "red"), Span(7, 12, "red on red"), Span(13, 24, "green")], 30 | id="trailing whitespace", 31 | ), 32 | pytest.param( 33 | textwrap.dedent( 34 | """\ 35 | --- file1 time1 36 | +++ file2 time2 37 | """ 38 | ), 39 | [Span(0, 15, "bold"), Span(16, 31, "bold")], 40 | id="header", 41 | ), 42 | pytest.param( 43 | "@@ line1,line2", 44 | [Span(0, 14, "cyan")], 45 | id="block header", 46 | ), 47 | ), 48 | ) 49 | def test_diff_highlighter(text, spans): 50 | diff_highlighter = colors.DiffHighlighter() 51 | 52 | actual = diff_highlighter(text) 53 | assert actual.spans == spans 54 | 55 | 56 | @pytest.mark.parametrize( 57 | ["text", "spans"], 58 | ( 59 | pytest.param( 60 | "1 file would be reformatted", 61 | [Span(0, 6, "blue"), Span(0, 27, "bold")], 62 | id="single file-reformatted-conditional", 63 | ), 64 | pytest.param( 65 | "1 file reformatted", 66 | [Span(0, 6, "blue"), Span(0, 18, "bold")], 67 | id="single file-reformatted", 68 | ), 69 | pytest.param( 70 | "26 files would be reformatted", 71 | [Span(0, 8, "blue"), Span(0, 29, "bold")], 72 | id="multiple files-reformatted-conditional", 73 | ), 74 | pytest.param( 75 | "26 files reformatted", 76 | [Span(0, 8, "blue"), Span(0, 20, "bold")], 77 | id="multiple files-reformatted", 78 | ), 79 | pytest.param( 80 | "1 file would be left unchanged", 81 | [Span(0, 6, "blue")], 82 | id="single file-unchanged-conditional", 83 | ), 84 | pytest.param( 85 | "1 file left unchanged", 86 | [Span(0, 6, "blue")], 87 | id="single file-unchanged", 88 | ), 89 | pytest.param( 90 | "26 files would be left unchanged", 91 | [Span(0, 8, "blue")], 92 | id="multiple files-unchanged-conditional", 93 | ), 94 | pytest.param( 95 | "26 files left unchanged", 96 | [Span(0, 8, "blue")], 97 | id="multiple files-unchanged", 98 | ), 99 | pytest.param( 100 | "1 file would fail to reformat", 101 | [Span(0, 29, "red")], 102 | id="single file-error-conditional", 103 | ), 104 | pytest.param( 105 | "1 file failed to reformat", 106 | [Span(0, 25, "red")], 107 | id="single file-error", 108 | ), 109 | pytest.param( 110 | "15 files would fail to reformat", 111 | [Span(0, 31, "red")], 112 | id="multiple files-error-conditional", 113 | ), 114 | pytest.param( 115 | "15 files failed to reformat", 116 | [Span(0, 27, "red")], 117 | id="multiple files-error", 118 | ), 119 | ), 120 | ) 121 | def test_file_highlighter(text, spans): 122 | highlighter = colors.FileHighlighter() 123 | 124 | actual = highlighter(text) 125 | assert actual.spans == spans 126 | -------------------------------------------------------------------------------- /blackdoc/tests/test_doctest.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import more_itertools 4 | import pytest 5 | 6 | from blackdoc import blacken 7 | from blackdoc.formats import doctest 8 | from blackdoc.tests.data import doctest as data 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ("string", "expected"), 13 | ( 14 | pytest.param("", None, id="empty string"), 15 | pytest.param("a", None, id="no quotes"), 16 | pytest.param("'''a'''", "'''", id="single quotes"), 17 | pytest.param('"""a"""', '"""', id="double quotes"), 18 | pytest.param('"a"""', None, id="trailing empty string"), 19 | pytest.param( 20 | textwrap.dedent( 21 | """\ 22 | ''' 23 | multiple lines 24 | ''' 25 | """ 26 | ).rstrip(), 27 | "'''", 28 | id="multiple lines single quotes", 29 | ), 30 | pytest.param( 31 | textwrap.dedent( 32 | '''\ 33 | """ 34 | multiple lines 35 | """ 36 | ''' 37 | ).rstrip(), 38 | '"""', 39 | id="multiple lines double quotes", 40 | ), 41 | ), 42 | ) 43 | def test_detect_docstring_quotes(string, expected): 44 | actual = doctest.detect_docstring_quotes(string) 45 | assert expected == actual 46 | 47 | 48 | @pytest.mark.parametrize( 49 | ["string", "expected"], 50 | ( 51 | pytest.param("", None, id="empty_line"), 52 | pytest.param("a function to open lines", None, id="no_code"), 53 | pytest.param(">>>", "doctest", id="empty_line_with_prompt"), 54 | pytest.param(">>> 10 * 5", "doctest", id="single_line"), 55 | pytest.param(" >>> 10 * 5", "doctest", id="single_line_with_indent"), 56 | pytest.param( 57 | textwrap.dedent( 58 | """\ 59 | >>> a = [ 60 | ... 1, 61 | ... 2, 62 | ... ] 63 | """ 64 | ), 65 | "doctest", 66 | id="multiple_lines", 67 | ), 68 | pytest.param( 69 | textwrap.dedent( 70 | """\ 71 | >>> ''' arbitrary triple-quoted string 72 | ... 73 | ... with empty continuation line 74 | ... ''' 75 | """ 76 | ), 77 | "doctest", 78 | id="multiple_lines_with_empty_continuation_line", 79 | ), 80 | pytest.param( 81 | textwrap.dedent( 82 | """\ 83 | >>> 84 | ... '''arbitrary triple-quoted string''' 85 | """ 86 | ), 87 | "doctest", 88 | id="multiple_lines_with_leading_empty_continuation_line", 89 | ), 90 | pytest.param( 91 | textwrap.dedent( 92 | """\ 93 | >>> '''arbitrary triple-quoted string''' 94 | ... 95 | """ 96 | ), 97 | "doctest", 98 | id="multiple_lines_with_trailing_empty_continuation_line", 99 | ), 100 | ), 101 | ) 102 | def test_detection_func(string, expected): 103 | def construct_expected(label, string): 104 | if label is None: 105 | return None 106 | 107 | n_lines = len(string.split("\n")) 108 | range_ = (1, n_lines + 1) 109 | return range_, label, string 110 | 111 | lines = string.split("\n") 112 | lines_ = more_itertools.peekable(enumerate(lines, start=1)) 113 | 114 | actual = doctest.detection_func(lines_) 115 | assert actual == construct_expected(expected, string.rstrip()) 116 | 117 | 118 | @pytest.mark.parametrize( 119 | ["code_unit", "expected"], 120 | ( 121 | pytest.param( 122 | ">>>", ({"prompt_length": 4, "docstring_quotes": None}, ""), id="empty_line" 123 | ), 124 | pytest.param( 125 | ">>> 10 * 5", 126 | ({"prompt_length": 4, "docstring_quotes": None}, "10 * 5"), 127 | id="single_line", 128 | ), 129 | pytest.param( 130 | textwrap.dedent( 131 | """\ 132 | >>> a = [ 133 | ... 1, 134 | ... 2, 135 | ... 3, 136 | ... ] 137 | """.rstrip() 138 | ), 139 | ( 140 | {"prompt_length": 4, "docstring_quotes": None}, 141 | textwrap.dedent( 142 | """\ 143 | a = [ 144 | 1, 145 | 2, 146 | 3, 147 | ] 148 | """.rstrip() 149 | ), 150 | ), 151 | id="multiple_lines", 152 | ), 153 | pytest.param( 154 | ">>> s = '''abc'''", 155 | ( 156 | {"prompt_length": 4, "docstring_quotes": "'''"}, 157 | "s = '''abc'''", 158 | ), 159 | id="triple_quoted_string-single_quotes", 160 | ), 161 | pytest.param( 162 | '>>> s = """abc"""', 163 | ( 164 | {"prompt_length": 4, "docstring_quotes": '"""'}, 165 | 's = """abc"""', 166 | ), 167 | id="triple_quoted_string-double_quotes", 168 | ), 169 | pytest.param( 170 | textwrap.dedent( 171 | """\ 172 | >>> ''' arbitrary triple-quoted string 173 | ... 174 | ... with empty continuation line 175 | ... ''' 176 | """.rstrip() 177 | ), 178 | ( 179 | {"prompt_length": 4, "docstring_quotes": "'''"}, 180 | textwrap.dedent( 181 | """\ 182 | ''' arbitrary triple-quoted string 183 | 184 | with empty continuation line 185 | ''' 186 | """.rstrip() 187 | ), 188 | ), 189 | id="multiple_lines_with_empty_continuation_line", 190 | ), 191 | ), 192 | ) 193 | def test_extraction_func(code_unit, expected): 194 | actual = doctest.extraction_func(code_unit) 195 | 196 | assert expected == actual 197 | 198 | 199 | def prepare_lines(lines, remove_prompt=False): 200 | dedented = (line.lstrip() for line in more_itertools.always_iterable(lines)) 201 | prepared = dedented if not remove_prompt else (line[4:] for line in dedented) 202 | return "\n".join(prepared) 203 | 204 | 205 | @pytest.mark.parametrize( 206 | ["code_unit", "docstring_quotes", "expected"], 207 | ( 208 | pytest.param( 209 | "file", 210 | None, 211 | ">>> file", 212 | id="single line", 213 | ), 214 | pytest.param( 215 | "", 216 | None, 217 | ">>>", 218 | id="single empty line", 219 | ), 220 | pytest.param( 221 | '"""docstring"""', 222 | '"""', 223 | '>>> """docstring"""', 224 | id="single-line triple-quoted string", 225 | ), 226 | pytest.param( 227 | textwrap.dedent( 228 | """\ 229 | a = [ 230 | 1, 231 | 2, 232 | ] 233 | """.rstrip() 234 | ), 235 | None, 236 | textwrap.dedent( 237 | """\ 238 | >>> a = [ 239 | ... 1, 240 | ... 2, 241 | ... ] 242 | """.rstrip() 243 | ), 244 | id="multiple lines", 245 | ), 246 | pytest.param( 247 | textwrap.dedent( 248 | """\ 249 | print("abc") 250 | print("def") 251 | """ 252 | ), 253 | None, 254 | textwrap.dedent( 255 | """\ 256 | >>> print("abc") 257 | >>> print("def") 258 | """.rstrip() 259 | ), 260 | id="multiple lines multiple statements", 261 | ), 262 | pytest.param( 263 | textwrap.dedent( 264 | """\ 265 | # comment 266 | print("abc") 267 | """ 268 | ), 269 | None, 270 | textwrap.dedent( 271 | """\ 272 | >>> # comment 273 | ... print("abc") 274 | """.rstrip() 275 | ), 276 | id="multiple lines with comment", 277 | ), 278 | pytest.param( 279 | textwrap.dedent( 280 | """\ 281 | # comment 282 | def func(): 283 | pass 284 | """ 285 | ), 286 | None, 287 | textwrap.dedent( 288 | """\ 289 | >>> # comment 290 | ... def func(): 291 | ... pass 292 | ... 293 | """.rstrip() 294 | ), 295 | id="multiple lines comment before block", 296 | ), 297 | pytest.param( 298 | textwrap.dedent( 299 | """\ 300 | @decorator 301 | def func(): 302 | pass 303 | """ 304 | ), 305 | None, 306 | textwrap.dedent( 307 | """\ 308 | >>> @decorator 309 | ... def func(): 310 | ... pass 311 | ... 312 | """.rstrip() 313 | ), 314 | id="multiple lines function decorator", 315 | ), 316 | pytest.param( 317 | textwrap.dedent( 318 | """\ 319 | @decorator 320 | class A: 321 | pass 322 | """ 323 | ), 324 | None, 325 | textwrap.dedent( 326 | """\ 327 | >>> @decorator 328 | ... class A: 329 | ... pass 330 | ... 331 | """.rstrip() 332 | ), 333 | id="multiple lines class decorator", 334 | ), 335 | pytest.param( 336 | textwrap.dedent( 337 | """\ 338 | ''' 339 | docstring content 340 | ''' 341 | """.rstrip() 342 | ), 343 | "'''", 344 | textwrap.dedent( 345 | """\ 346 | >>> ''' 347 | ... docstring content 348 | ... ''' 349 | """.rstrip() 350 | ), 351 | id="multi-line triple-quoted string-single quotes", 352 | ), 353 | pytest.param( 354 | textwrap.dedent( 355 | """\ 356 | s = ''' 357 | triple-quoted string 358 | ''' 359 | """ 360 | ).rstrip(), 361 | '"""', 362 | textwrap.dedent( 363 | '''\ 364 | >>> s = """ 365 | ... triple-quoted string 366 | ... """ 367 | '''.rstrip(), 368 | ), 369 | id="multi-line triple-quoted string-double quotes", 370 | ), 371 | pytest.param( 372 | textwrap.dedent( 373 | """\ 374 | ''' arbitrary triple-quoted string 375 | 376 | with a empty continuation line 377 | ''' 378 | """.rstrip(), 379 | ), 380 | "'''", 381 | textwrap.dedent( 382 | """\ 383 | >>> ''' arbitrary triple-quoted string 384 | ... 385 | ... with a empty continuation line 386 | ... ''' 387 | """.rstrip(), 388 | ), 389 | id="multi-line triple-quoted string with empty continuation line", 390 | ), 391 | pytest.param( 392 | '"""inverted quotes"""', 393 | '"""', 394 | '>>> """inverted quotes"""', 395 | id="triple-quoted string with inverted quotes", 396 | ), 397 | pytest.param( 398 | textwrap.dedent( 399 | """\ 400 | def myfunc(arg1, arg2): 401 | pass 402 | """ 403 | ), 404 | None, 405 | textwrap.dedent( 406 | """\ 407 | >>> def myfunc(arg1, arg2): 408 | ... pass 409 | ... 410 | """.rstrip() 411 | ), 412 | id="trailing newline at the end of a block", 413 | ), 414 | pytest.param( 415 | textwrap.dedent( 416 | """\ 417 | a = 1 418 | 419 | """ 420 | ), 421 | None, 422 | ">>> a = 1", 423 | id="trailing newline at the end of a normal line", 424 | ), 425 | pytest.param( 426 | "# this is not a block:", 427 | None, 428 | ">>> # this is not a block:", 429 | id="trailing colon at the end of a comment", 430 | ), 431 | pytest.param( 432 | textwrap.dedent( 433 | """\ 434 | def f(arg1, arg2): 435 | ''' nested docstring 436 | 437 | parameter description 438 | ''' 439 | """ 440 | ), 441 | "'''", 442 | textwrap.dedent( 443 | """\ 444 | >>> def f(arg1, arg2): 445 | ... ''' nested docstring 446 | ... 447 | ... parameter description 448 | ... ''' 449 | ... 450 | """.rstrip() 451 | ), 452 | id="nested docstring", 453 | ), 454 | pytest.param( 455 | "s = '''triple-quoted string'''", 456 | '"""', 457 | '>>> s = """triple-quoted string"""', 458 | id="triple-quoted string", 459 | ), 460 | pytest.param( 461 | textwrap.dedent( 462 | '''\ 463 | def f(arg1, arg2): 464 | """ docstring """ 465 | s = "trailing empty string""" 466 | ''' 467 | ), 468 | "'''", 469 | textwrap.dedent( 470 | """\ 471 | >>> def f(arg1, arg2): 472 | ... ''' docstring ''' 473 | ... s = "trailing empty string\""" 474 | ... 475 | """.rstrip() 476 | ), 477 | id="docstring and trailing empty string", 478 | ), 479 | pytest.param( 480 | textwrap.dedent( 481 | '''\ 482 | def f(arg1, arg2): 483 | """ docstring """ 484 | s = """triple-quoted string""" 485 | ''' 486 | ), 487 | "'''", 488 | textwrap.dedent( 489 | """\ 490 | >>> def f(arg1, arg2): 491 | ... ''' docstring ''' 492 | ... s = '''triple-quoted string''' 493 | ... 494 | """.rstrip() 495 | ), 496 | id="docstring and triple-quoted string", 497 | ), 498 | ), 499 | ) 500 | def test_reformatting_func(code_unit, docstring_quotes, expected): 501 | actual = doctest.reformatting_func(code_unit, docstring_quotes) 502 | assert expected == actual 503 | 504 | # make sure the docstring quotes were not changed 505 | expected_quotes = doctest.detect_docstring_quotes(expected) 506 | actual_quotes = doctest.detect_docstring_quotes(actual) 507 | assert expected_quotes == actual_quotes 508 | 509 | 510 | def test_blacken(): 511 | labeled = tuple( 512 | ((min_ + 1, max_ + 1), label, "\n".join(data.lines[slice(min_, max_)])) 513 | for label, (min_, max_) in zip(data.line_labels, data.line_ranges) 514 | ) 515 | actual = tuple(blacken(labeled)) 516 | 517 | assert len("\n".join(actual).split("\n")) == 32 518 | -------------------------------------------------------------------------------- /blackdoc/tests/test_init.py: -------------------------------------------------------------------------------- 1 | import blackdoc 2 | 3 | 4 | def test_version(): 5 | assert getattr(blackdoc, "__version__", "") not in ("", "999") 6 | -------------------------------------------------------------------------------- /blackdoc/tests/test_ipython.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import more_itertools 4 | import pytest 5 | 6 | from blackdoc import blacken 7 | from blackdoc.formats import ipython 8 | from blackdoc.tests.data import ipython as data 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ["lines", "expected"], 13 | ( 14 | pytest.param("xyz def", None, id="no_line"), 15 | pytest.param( 16 | " In [2]: file", 17 | ((1, 2), ipython.name, " In [2]: file"), 18 | id="single_line", 19 | ), 20 | pytest.param( 21 | [ 22 | "In [1]: file = open(", 23 | ' ...: "very_long_filepath",', 24 | ' ...: mode="a",', 25 | " ...: )", 26 | ], 27 | ( 28 | (1, 5), 29 | ipython.name, 30 | textwrap.dedent( 31 | """\ 32 | In [1]: file = open( 33 | ...: "very_long_filepath", 34 | ...: mode="a", 35 | ...: ) 36 | """.rstrip() 37 | ), 38 | ), 39 | id="multiple_lines", 40 | ), 41 | ), 42 | ) 43 | def test_detection_func(lines, expected): 44 | lines = more_itertools.peekable( 45 | enumerate(more_itertools.always_iterable(lines), start=1) 46 | ) 47 | 48 | actual = ipython.detection_func(lines) 49 | assert actual == expected 50 | 51 | 52 | @pytest.mark.parametrize( 53 | ["line", "expected"], 54 | ( 55 | pytest.param( 56 | "In [2]: file", 57 | ({"count": 2}, "file"), 58 | id="single_line", 59 | ), 60 | pytest.param( 61 | textwrap.dedent( 62 | """\ 63 | In [1]: file = open( 64 | ...: "very_long_filepath", 65 | ...: mode="a", 66 | ...: ) 67 | """.rstrip() 68 | ), 69 | ( 70 | {"count": 1}, 71 | "\n".join( 72 | [ 73 | "file = open(", 74 | ' "very_long_filepath",', 75 | ' mode="a",', 76 | ")", 77 | ] 78 | ), 79 | ), 80 | id="multiple_lines", 81 | ), 82 | pytest.param( 83 | textwrap.dedent( 84 | """\ 85 | In [4]: %%time 86 | ...: file.close() 87 | """.rstrip() 88 | ), 89 | ( 90 | {"count": 4}, 91 | textwrap.dedent( 92 | f"""\ 93 | # {ipython.magic_comment}%%time 94 | file.close() 95 | """.rstrip() 96 | ), 97 | ), 98 | id="lines_with_cell_magic", 99 | ), 100 | pytest.param( 101 | textwrap.dedent( 102 | """\ 103 | In [5]: @savefig simple.png width=4in 104 | ...: @property 105 | ...: def my_property(self): 106 | ...: pass 107 | """.rstrip() 108 | ), 109 | ( 110 | {"count": 5}, 111 | textwrap.dedent( 112 | f"""\ 113 | # {ipython.magic_comment}@savefig simple.png width=4in 114 | @property 115 | def my_property(self): 116 | pass 117 | """.rstrip() 118 | ), 119 | ), 120 | id="lines_with_line_decorator", 121 | ), 122 | ), 123 | ) 124 | def test_extraction_func(line, expected): 125 | actual = ipython.extraction_func(line) 126 | 127 | assert expected == actual 128 | 129 | 130 | @pytest.mark.parametrize( 131 | ["line", "count", "expected"], 132 | ( 133 | pytest.param("file", 2, "In [2]: file", id="single_line"), 134 | pytest.param( 135 | textwrap.dedent( 136 | """\ 137 | file = open( 138 | "very_long_filepath", 139 | mode="a", 140 | ) 141 | """.rstrip() 142 | ), 143 | 1, 144 | textwrap.dedent( 145 | """\ 146 | In [1]: file = open( 147 | ...: "very_long_filepath", 148 | ...: mode="a", 149 | ...: ) 150 | """.rstrip() 151 | ), 152 | id="multiple_lines", 153 | ), 154 | pytest.param( 155 | textwrap.dedent( 156 | f"""\ 157 | # {ipython.magic_comment}%%time 158 | file.close() 159 | """.rstrip() 160 | ), 161 | 4, 162 | textwrap.dedent( 163 | """\ 164 | In [4]: %%time 165 | ...: file.close() 166 | """.rstrip() 167 | ), 168 | id="lines_with_cell_magic", 169 | ), 170 | pytest.param( 171 | textwrap.dedent( 172 | f"""\ 173 | # {ipython.magic_comment}@savefig simple.png width=4in 174 | @property 175 | def my_property(self): 176 | pass 177 | """.rstrip() 178 | ), 179 | 5, 180 | textwrap.dedent( 181 | """\ 182 | In [5]: @savefig simple.png width=4in 183 | ...: @property 184 | ...: def my_property(self): 185 | ...: pass 186 | """.rstrip() 187 | ), 188 | id="lines_with_line_decorator", 189 | ), 190 | ), 191 | ) 192 | def test_reformatting_func(line, count, expected): 193 | actual = ipython.reformatting_func(line, count=count) 194 | assert expected == actual 195 | 196 | 197 | def test_blacken(): 198 | labeled = list( 199 | ( 200 | (min_, max_), 201 | label, 202 | "\n".join(data.lines[min_:max_]), 203 | ) 204 | for (min_, max_), label in zip(data.line_ranges, data.line_labels) 205 | ) 206 | actual = tuple(blacken(labeled)) 207 | 208 | assert len(actual) == 19 209 | -------------------------------------------------------------------------------- /blackdoc/tests/test_none.py: -------------------------------------------------------------------------------- 1 | from blackdoc.formats import none 2 | from blackdoc.tests.data.doctest import lines 3 | 4 | 5 | def test_detection_func(): 6 | line_range = (1, 2) 7 | line = lines[0] 8 | name = none.name 9 | 10 | assert none.detection_func(enumerate(lines, start=1)) == (line_range, name, line) 11 | 12 | 13 | def test_extraction_func(): 14 | parameters = {} 15 | line = lines[0] 16 | 17 | assert none.extraction_func(line) == (parameters, line) 18 | 19 | 20 | def test_reformatting_func(): 21 | line = lines[0] 22 | 23 | assert none.reformatting_func(line) == line 24 | -------------------------------------------------------------------------------- /blackdoc/tests/test_report.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from blackdoc import report 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ["sources", "expected"], 8 | ( 9 | pytest.param( 10 | { 11 | "file1.py": "reformatted", 12 | "file2.py": "reformatted", 13 | "file3.rst": "reformatted", 14 | }, 15 | (3, 0, 0), 16 | id="3 reformatted-0 unchanged-0 failed", 17 | ), 18 | pytest.param( 19 | { 20 | "file1.py": "unchanged", 21 | "file2.py": "unchanged", 22 | "file3.rst": "unchanged", 23 | }, 24 | (0, 3, 0), 25 | id="0 reformatted-3 unchanged-0 failed", 26 | ), 27 | pytest.param( 28 | {"file1.py": "error", "file2.py": "error", "file3.rst": "error"}, 29 | (0, 0, 3), 30 | id="0 reformatted-0 unchanged-3 failed", 31 | ), 32 | pytest.param( 33 | {"file1.py": "reformatted", "file2.py": "unchanged", "file3.rst": "error"}, 34 | (1, 1, 1), 35 | id="1 reformatted-1 unchanged-1 failed", 36 | ), 37 | ), 38 | ) 39 | def test_statistics(sources, expected): 40 | actual = report.statistics(sources) 41 | assert actual == expected 42 | 43 | 44 | @pytest.mark.parametrize( 45 | ["statistics", "conditional", "expected"], 46 | ( 47 | pytest.param( 48 | {"n_reformatted": 0, "n_unchanged": 1, "n_error": 0}, 49 | False, 50 | ["1 file left unchanged"], 51 | id="0 reformatted-1 unchanged-0 failed", 52 | ), 53 | pytest.param( 54 | {"n_reformatted": 0, "n_unchanged": 1, "n_error": 0}, 55 | True, 56 | ["1 file would be left unchanged"], 57 | id="0 reformatted-1 unchanged-0 failed-conditional", 58 | ), 59 | pytest.param( 60 | {"n_reformatted": 0, "n_unchanged": 4, "n_error": 0}, 61 | False, 62 | ["4 files left unchanged"], 63 | id="0 reformatted-4 unchanged-0 failed", 64 | ), 65 | pytest.param( 66 | {"n_reformatted": 0, "n_unchanged": 4, "n_error": 0}, 67 | True, 68 | ["4 files would be left unchanged"], 69 | id="0 reformatted-4 unchanged-0 failed-conditional", 70 | ), 71 | pytest.param( 72 | {"n_reformatted": 1, "n_unchanged": 0, "n_error": 0}, 73 | False, 74 | ["1 file reformatted"], 75 | id="1 reformatted-0 unchanged-0 failed", 76 | ), 77 | pytest.param( 78 | {"n_reformatted": 1, "n_unchanged": 0, "n_error": 0}, 79 | True, 80 | ["1 file would be reformatted"], 81 | id="1 reformatted-0 unchanged-0 failed-conditional", 82 | ), 83 | pytest.param( 84 | {"n_reformatted": 4, "n_unchanged": 0, "n_error": 0}, 85 | False, 86 | ["4 files reformatted"], 87 | id="4 reformatted-0 unchanged-0 failed", 88 | ), 89 | pytest.param( 90 | {"n_reformatted": 4, "n_unchanged": 0, "n_error": 0}, 91 | True, 92 | ["4 files would be reformatted"], 93 | id="4 reformatted-0 unchanged-0 failed-conditional", 94 | ), 95 | pytest.param( 96 | {"n_reformatted": 0, "n_unchanged": 0, "n_error": 1}, 97 | False, 98 | ["1 file failed to reformat"], 99 | id="0 reformatted-0 unchanged-1 failed", 100 | ), 101 | pytest.param( 102 | {"n_reformatted": 0, "n_unchanged": 0, "n_error": 1}, 103 | True, 104 | ["1 file would fail to reformat"], 105 | id="0 reformatted-0 unchanged-1 failed-conditional", 106 | ), 107 | pytest.param( 108 | {"n_reformatted": 0, "n_unchanged": 0, "n_error": 4}, 109 | False, 110 | ["4 files failed to reformat"], 111 | id="0 reformatted-0 unchanged-4 failed", 112 | ), 113 | pytest.param( 114 | {"n_reformatted": 0, "n_unchanged": 0, "n_error": 4}, 115 | True, 116 | ["4 files would fail to reformat"], 117 | id="0 reformatted-0 unchanged-4 failed-conditional", 118 | ), 119 | pytest.param( 120 | {"n_reformatted": 1, "n_unchanged": 1, "n_error": 1}, 121 | False, 122 | [ 123 | "1 file reformatted", 124 | "1 file left unchanged", 125 | "1 file failed to reformat", 126 | ], 127 | id="1 reformatted-1 unchanged-1 failed", 128 | ), 129 | pytest.param( 130 | {"n_reformatted": 1, "n_unchanged": 1, "n_error": 1}, 131 | True, 132 | [ 133 | "1 file would be reformatted", 134 | "1 file would be left unchanged", 135 | "1 file would fail to reformat", 136 | ], 137 | id="1 reformatted-1 unchanged-1 failed-conditional", 138 | ), 139 | pytest.param( 140 | {"n_reformatted": 4, "n_unchanged": 4, "n_error": 4}, 141 | False, 142 | [ 143 | "4 files reformatted", 144 | "4 files left unchanged", 145 | "4 files failed to reformat", 146 | ], 147 | id="4 reformatted-4 unchanged-4 failed", 148 | ), 149 | pytest.param( 150 | {"n_reformatted": 4, "n_unchanged": 4, "n_error": 4}, 151 | True, 152 | [ 153 | "4 files would be reformatted", 154 | "4 files would be left unchanged", 155 | "4 files would fail to reformat", 156 | ], 157 | id="4 reformatted-4 unchanged-4 failed-conditional", 158 | ), 159 | ), 160 | ) 161 | def test_report_parts(statistics, conditional, expected): 162 | r = report.Report(conditional=conditional, **statistics) 163 | actual = r._report_parts() 164 | assert actual == expected 165 | -------------------------------------------------------------------------------- /blackdoc/tests/test_rst.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import more_itertools 4 | import pytest 5 | 6 | from blackdoc import blacken 7 | from blackdoc.formats import rst 8 | from blackdoc.tests.data import rst as data 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ("string", "expected"), 13 | ( 14 | pytest.param("", None, id="empty string"), 15 | pytest.param("Some string.", None, id="no_code"), 16 | pytest.param( 17 | textwrap.dedent( 18 | """\ 19 | .. note:: 20 | 21 | This is not a code block. 22 | """ 23 | ), 24 | None, 25 | id="block", 26 | ), 27 | pytest.param( 28 | textwrap.dedent( 29 | """\ 30 | .. code:: sh 31 | 32 | find . -name "*.py" 33 | """ 34 | ), 35 | None, 36 | id="code_other_language", 37 | ), 38 | pytest.param( 39 | textwrap.dedent( 40 | """\ 41 | .. code:: python 42 | 43 | 10 * 5 44 | """ 45 | ), 46 | "rst", 47 | id="code", 48 | ), 49 | pytest.param( 50 | textwrap.dedent( 51 | """\ 52 | .. code-block:: python 53 | 54 | 10 * 5 55 | """ 56 | ), 57 | "rst", 58 | id="code-block", 59 | ), 60 | pytest.param( 61 | textwrap.dedent( 62 | """\ 63 | .. ipython:: python 64 | 65 | %%time 66 | 10 * 5 67 | """ 68 | ), 69 | "rst", 70 | id="ipython", 71 | ), 72 | pytest.param( 73 | textwrap.dedent( 74 | """\ 75 | .. ipython:: 76 | 77 | In [1]: 10 * 5 78 | Out[1]: 50 79 | 80 | In [2]: %%time 81 | ...: ".".join("abc") 82 | Out[2]: 'a.b.c' 83 | """ 84 | ), 85 | None, 86 | id="ipython-prompt", 87 | ), 88 | pytest.param( 89 | textwrap.dedent( 90 | """\ 91 | .. ipython:: 92 | :okerror: 93 | 94 | @verbatim 95 | In [1]: 10 * 5 96 | Out[1]: 50 97 | """ 98 | ), 99 | None, 100 | id="ipython-prompt-cell-decorator", 101 | ), 102 | pytest.param( 103 | textwrap.dedent( 104 | """\ 105 | .. testsetup:: 106 | 107 | 10 * 5 108 | """ 109 | ), 110 | "rst", 111 | id="testsetup", 112 | ), 113 | pytest.param( 114 | textwrap.dedent( 115 | """\ 116 | .. testcode:: 117 | 118 | 10 * 5 119 | """ 120 | ), 121 | "rst", 122 | id="testcode", 123 | ), 124 | pytest.param( 125 | textwrap.dedent( 126 | """\ 127 | .. testcleanup:: 128 | 129 | 10 * 5 130 | """ 131 | ), 132 | "rst", 133 | id="testcleanup", 134 | ), 135 | pytest.param( 136 | textwrap.dedent( 137 | """\ 138 | .. ipython:: python 139 | print("abc") 140 | """ 141 | ), 142 | "rst", 143 | id="missing option separator", 144 | ), 145 | ), 146 | ) 147 | def test_detection_func(string, expected): 148 | def construct_expected(label, string): 149 | if label is None: 150 | return None 151 | 152 | n_lines = len(string.split("\n")) 153 | 154 | range_ = (1, n_lines + 1) 155 | return range_, label, string 156 | 157 | lines = string.split("\n") 158 | code_fragment = more_itertools.peekable(enumerate(lines, start=1)) 159 | actual = rst.detection_func(code_fragment) 160 | 161 | assert actual == construct_expected(expected, string.rstrip()) 162 | 163 | 164 | @pytest.mark.parametrize( 165 | "code,expected", 166 | ( 167 | pytest.param( 168 | textwrap.dedent( 169 | """\ 170 | .. code:: python 171 | 172 | 10 * 5 173 | """.rstrip() 174 | ), 175 | ( 176 | { 177 | "name": "code", 178 | "language": "python", 179 | "options": (), 180 | "prompt_length": 3, 181 | "n_header_lines": 2, 182 | }, 183 | "10 * 5", 184 | ), 185 | id="code", 186 | ), 187 | pytest.param( 188 | textwrap.dedent( 189 | """\ 190 | .. code:: python 191 | :okwarning: 192 | 193 | 10 * 5 194 | """.rstrip() 195 | ), 196 | ( 197 | { 198 | "name": "code", 199 | "language": "python", 200 | "options": (":okwarning:",), 201 | "prompt_length": 3, 202 | "n_header_lines": 3, 203 | }, 204 | "10 * 5", 205 | ), 206 | id="code_with_options", 207 | ), 208 | pytest.param( 209 | textwrap.dedent( 210 | """\ 211 | .. code-block:: python 212 | 213 | 10 * 5 214 | """.rstrip() 215 | ), 216 | ( 217 | { 218 | "name": "code-block", 219 | "language": "python", 220 | "options": (), 221 | "prompt_length": 3, 222 | "n_header_lines": 2, 223 | }, 224 | "10 * 5", 225 | ), 226 | id="code-block", 227 | ), 228 | pytest.param( 229 | textwrap.dedent( 230 | """\ 231 | .. ipython:: 232 | 233 | %%time 234 | 10 * 5 235 | """.rstrip() 236 | ), 237 | ( 238 | { 239 | "name": "ipython", 240 | "language": None, 241 | "options": (), 242 | "prompt_length": 4, 243 | "n_header_lines": 2, 244 | }, 245 | textwrap.dedent( 246 | """\ 247 | # %%time 248 | 10 * 5 249 | """.rstrip() 250 | ), 251 | ), 252 | id="ipython", 253 | ), 254 | pytest.param( 255 | textwrap.dedent( 256 | """\ 257 | .. ipython:: python 258 | 10 * 5 259 | """.rstrip() 260 | ), 261 | ( 262 | { 263 | "name": "ipython", 264 | "language": "python", 265 | "options": (), 266 | "prompt_length": 4, 267 | "n_header_lines": 2, 268 | }, 269 | "10 * 5", 270 | ), 271 | id="missing_eof_line", 272 | ), 273 | pytest.param( 274 | textwrap.dedent( 275 | """\ 276 | .. ipython:: python 277 | print("abc") 278 | """.rstrip() 279 | ), 280 | ( 281 | { 282 | "name": "ipython", 283 | "language": "python", 284 | "options": (), 285 | "prompt_length": 4, 286 | "n_header_lines": 2, 287 | }, 288 | 'print("abc")', 289 | ), 290 | id="missing_sep_line", 291 | ), 292 | ), 293 | ) 294 | def test_extraction_func(code, expected): 295 | actual = rst.extraction_func(code) 296 | 297 | assert expected == actual 298 | 299 | 300 | @pytest.mark.parametrize( 301 | ("code", "directive", "expected"), 302 | ( 303 | pytest.param( 304 | "10 * 5", 305 | { 306 | "name": "code", 307 | "language": "python", 308 | "options": (), 309 | "prompt_length": 3, 310 | }, 311 | textwrap.dedent( 312 | """\ 313 | .. code:: python 314 | 315 | 10 * 5 316 | """.rstrip() 317 | ), 318 | id="code", 319 | ), 320 | pytest.param( 321 | "10 * 5", 322 | { 323 | "name": "code-block", 324 | "language": "python", 325 | "options": (":okwarning:",), 326 | "prompt_length": 4, 327 | }, 328 | textwrap.dedent( 329 | """\ 330 | .. code-block:: python 331 | :okwarning: 332 | 333 | 10 * 5 334 | """.rstrip() 335 | ), 336 | id="code-block_with_options", 337 | ), 338 | pytest.param( 339 | "10 * 5", 340 | {"name": "ipython", "language": None, "options": (), "prompt_length": 4}, 341 | textwrap.dedent( 342 | """\ 343 | .. ipython:: 344 | 345 | 10 * 5 346 | """.rstrip() 347 | ), 348 | id="ipython", 349 | ), 350 | ), 351 | ) 352 | def test_reformatting_func(code, directive, expected): 353 | actual = rst.reformatting_func(code, **directive) 354 | 355 | assert expected == actual 356 | 357 | 358 | def test_blacken(): 359 | labeled = tuple( 360 | ((min_ + 1, max_ + 1), label, "\n".join(data.lines[slice(min_, max_)])) 361 | for label, (min_, max_) in zip(data.line_labels, data.line_ranges) 362 | ) 363 | actual = tuple(blacken(labeled)) 364 | 365 | assert len("\n".join(actual).split("\n")) == 76 366 | -------------------------------------------------------------------------------- /ci/requirements/doc.txt: -------------------------------------------------------------------------------- 1 | sphinx>=5 2 | furo 3 | ipython 4 | more-itertools 5 | -------------------------------------------------------------------------------- /ci/requirements/environment.yml: -------------------------------------------------------------------------------- 1 | name: blackdoc 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python 6 | - black 7 | - rich 8 | - more-itertools 9 | - ruff 10 | - pytest 11 | - pip 12 | -------------------------------------------------------------------------------- /ci/requirements/normal.txt: -------------------------------------------------------------------------------- 1 | more-itertools 2 | black 3 | rich 4 | pytest 5 | -------------------------------------------------------------------------------- /ci/requirements/upstream-dev.txt: -------------------------------------------------------------------------------- 1 | git+https://github.com/psf/black 2 | git+https://github.com/more-itertools/more-itertools 3 | git+https://github.com/textualize/rich 4 | git+https://github.com/hukkin/tomli 5 | pytest 6 | pytest-reportlog 7 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | black 2 | more-itertools 3 | flake8 4 | isort 5 | pytest 6 | coverage 7 | -------------------------------------------------------------------------------- /doc/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | v0.3.10 (*unreleased*) 4 | ---------------------- 5 | - officially support running on python 3.13 (:pull:`216`) 6 | - drop support for running on python 3.8 (:pull:`215`) 7 | 8 | v0.3.9 (04 November 2023) 9 | ------------------------- 10 | - support synchronizing the version of the ``black`` hook in more cases (:pull:`180`) 11 | - document the ``pre-commit`` hooks (:issue:`176`, :pull:`181`) 12 | - officially support running on python 3.12 (:pull:`185`) 13 | - drop support for running on python 3.7 (:pull:`186`) 14 | 15 | v0.3.8 (03 November 2022) 16 | ------------------------- 17 | - use the ``doctest`` formatter for doctest lines in ``rst`` code blocks (:issue:`150`, :pull:`151`) 18 | - drop support for ``python=3.6`` (:pull:`153`) 19 | - split chained statements into multiple ``doctest`` lines (:issue:`143`, :pull:`155`, :pull:`158`) 20 | - replace the custom color formatting code with `rich `_ 21 | (:issue:`146`, :pull:`157`, :pull:`159`, :pull:`160`). 22 | - officially support python 3.11 (:pull:`163`) 23 | 24 | v0.3.7 (13 September 2022) 25 | -------------------------- 26 | - replace docstrings by modifying by token (:issue:`142`, :pull:`144`) 27 | - switch the html theme to `furo `_ (:pull:`149`) 28 | - add a new hook to synchronize `black` pinned in `additional_dependencies` with the version from 29 | the `black` hook (:pull:`124`) 30 | 31 | v0.3.6 (25 August 2022) 32 | ----------------------- 33 | - fix a regression in the doctest format that would either truncate 34 | the reformatted code or crash it (:pull:`137`) 35 | 36 | v0.3.5 (26 July 2022) 37 | --------------------- 38 | - officially support python 3.10 (:pull:`115`) 39 | - colorize removed trailing whitespace (:pull:`120`) 40 | - write only if the content of a file changed (:issue:`127`, :pull:`128`) 41 | - don't crash on strings with trailing empty strings (`"a"""`) (:issue:`131`, :pull:`132`) 42 | 43 | v0.3.4 (17 July 2021) 44 | --------------------- 45 | - declare the ``tomli`` library as a runtime dependency (:pull:`101`) 46 | 47 | v0.3.3 (06 February 2021) 48 | ------------------------- 49 | - don't crash on malformed rst directives (:issue:`78`, :pull:`79`) 50 | 51 | v0.3.2 (05 January 2021) 52 | ------------------------ 53 | - don't strip newlines immediately before eol (:pull:`73`) 54 | 55 | v0.3.1 (04 December 2020) 56 | ------------------------- 57 | - don't detect comments ending with a colon as a block (:issue:`67`, :pull:`68`) 58 | - don't add color to redirected output and print reports to ``stderr`` (:issue:`66`, :pull:`69`) 59 | - add a nightly CI which also runs every day at 00:00 UTC (:pull:`71`) 60 | 61 | v0.3 (04 November 2020) 62 | ----------------------- 63 | - support running on python 3.9 (the target version is not yet supported by black) 64 | (:pull:`55`, :pull:`57`) 65 | - add diff and color diff modes (:issue:`33`, :issue:`53`, :pull:`56`) 66 | - support `black`'s string normalization option (:issue:`33`, :pull:`59`) 67 | - add colors to the output (:issue:`33`, :pull:`60`) 68 | - make the order of the printed files predictable (:pull:`61`) 69 | - make sure blocks end with a empty continuation line (:issue:`52`, :pull:`62`) 70 | - add a initial version of a contributing guide (:pull:`63`) 71 | 72 | 73 | v0.2 (01 October 2020) 74 | ---------------------- 75 | - Support the :rst:dir:`testcode`, :rst:dir:`testsetup` and 76 | :rst:dir:`testcleanup` directives (:pull:`39`). 77 | - Fix working with lines containing only the prompt and avoid changing the 78 | quotes of nested docstrings (:issue:`41`, :pull:`43`) 79 | - Allow configuring ``blackdoc`` using ``pyproject.toml`` 80 | (:issue:`40`, :pull:`45`, :pull:`47`) 81 | - Add a ``force-exclude`` option (:pull:`49`) 82 | - Document the options (:pull:`50`) 83 | 84 | 85 | v0.1.2 (31 August 2020) 86 | ----------------------- 87 | - Keep compatibility with ``black`` 20.8b1 (:issue:`33`, :pull:`34`) 88 | 89 | v0.1.1 (14 June 2020) 90 | --------------------- 91 | - Add pre-commit hook configuration (:pull:`26`, :pull:`27`) 92 | - Document the release process (:pull:`29`) 93 | - Make sure the tool returns a non-zero error code when encountering 94 | syntax errors (:pull:`28`) 95 | 96 | 97 | v0.1 (30 May 2020) 98 | ------------------ 99 | 100 | - Add a CLI (:pull:`1`) 101 | - Add support for ipython prompts (:pull:`4`) 102 | - Add support for code blocks in rst files (:pull:`10`) 103 | - Allow disabling / selectively enabling formats (:issue:`13`, :pull:`18`) 104 | - Initial version of the documentation (:issue:`12`, :pull:`19`) 105 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -- Path setup -------------------------------------------------------------- 2 | 3 | # If extensions (or modules to document with autodoc) are in another directory, 4 | # add these directories to sys.path here. If the directory is relative to the 5 | # documentation root, use os.path.abspath to make it absolute, like shown here. 6 | # 7 | # import os 8 | # import sys 9 | # sys.path.insert(0, os.path.abspath('.')) 10 | 11 | 12 | # -- Project information ----------------------------------------------------- 13 | import datetime as dt 14 | 15 | project = "blackdoc" 16 | author = f"{project} developers" 17 | year = dt.datetime.now().year 18 | copyright = f"2020-{year}, {author}" 19 | 20 | # The master toctree document. 21 | master_doc = "index" 22 | 23 | # -- General configuration --------------------------------------------------- 24 | 25 | # Add any Sphinx extension module names here, as strings. They can be 26 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 27 | # ones. 28 | extensions = [ 29 | "sphinx.ext.extlinks", 30 | "sphinx.ext.intersphinx", 31 | "IPython.sphinxext.ipython_directive", 32 | "IPython.sphinxext.ipython_console_highlighting", 33 | ] 34 | 35 | extlinks = { 36 | "issue": ("https://github.com/keewis/blackdoc/issues/%s", "GH%s"), 37 | "pull": ("https://github.com/keewis/blackdoc/pull/%s", "PR%s"), 38 | } 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ["_templates"] 42 | 43 | # List of patterns, relative to source directory, that match files and 44 | # directories to ignore when looking for source files. 45 | # This pattern also affects html_static_path and html_extra_path. 46 | exclude_patterns = ["_build", "directory"] 47 | 48 | 49 | # -- Options for HTML output ------------------------------------------------- 50 | 51 | # The theme to use for HTML and HTML Help pages. See the documentation for 52 | # a list of builtin themes. 53 | # 54 | html_theme = "furo" 55 | 56 | # Add any paths that contain custom static files (such as style sheets) here, 57 | # relative to this directory. They are copied after the builtin static files, 58 | # so a file named "default.css" will overwrite the builtin "default.css". 59 | # html_static_path = ["_static"] 60 | 61 | # -- Options for the intersphinx extension ----------------------------------- 62 | 63 | intersphinx_mapping = { 64 | "python": ("https://docs.python.org/3/", None), 65 | "sphinx": ("https://www.sphinx-doc.org/en/stable/", None), 66 | } 67 | -------------------------------------------------------------------------------- /doc/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | ``blackdoc`` uses several tools to ensure consistency (this is enforced using CI): 5 | 6 | - `black`_ for standardized code formatting 7 | - `flake8`_ for code quality 8 | - `isort`_ for standardized ordering of imports 9 | 10 | To avoid having to remember to manually run these tools before committing, using 11 | `pre-commit`_ is possible. After installing, enable it using 12 | 13 | .. code:: sh 14 | 15 | python -m pip install pre-commit 16 | # or 17 | conda install -c conda-forge pre-commit 18 | 19 | pre-commit install 20 | 21 | When modifying the test data in ``blackdoc/tests/data/format.py``, make sure the ranges are 22 | correct using 23 | 24 | .. code:: sh 25 | 26 | python -m blackdoc.tests.data format 27 | 28 | where ``format`` is a placeholder for the name of one of the supported formats. 29 | 30 | 31 | .. _black: https://black.readthedocs.io/en/stable/ 32 | .. _flake8: https://flake8.pycqa.org/en/stable/ 33 | .. _isort: https://pycqa.github.io/isort/ 34 | .. _pre-commit: https://pre-commit.com/ 35 | -------------------------------------------------------------------------------- /doc/directory/file.py: -------------------------------------------------------------------------------- 1 | # directory/file.py 2 | """ 3 | docstring with code. 4 | 5 | doctests: 6 | >>> s={ "a", "b","c", 'd', "e", "f", "e", 7 | ... "f", "g",'h', "i", "j", "k",'l', 'm', 8 | ... } 9 | 10 | >>> s = ( 11 | ... "a" 12 | ... + "b" 13 | ... ) 14 | 15 | >>> def f(): 16 | ... '''nested docstring 17 | ... 18 | ... parameter documentation 19 | ... ''' 20 | ... 21 | ... s = ( 22 | ... '''triple-quoted string''' 23 | ... ) 24 | ... 25 | 26 | ipython: 27 | In [1]: d= { "a": 0, "b": 1, "c": 2, 28 | ...: "d": 3, "e": 4, "f": 5, "g": 6, 29 | ...: "h": 7, "i": 8, "j": 9 } 30 | """ 31 | -------------------------------------------------------------------------------- /doc/directory/file.rst: -------------------------------------------------------------------------------- 1 | .. directory/file.rst 2 | file with code: 3 | 4 | .. code:: python 5 | 6 | def function_with_long_name(parameter1, parameter2, 7 | parameter3, *variable_args, **keyword_args): 8 | pass 9 | 10 | more code: 11 | 12 | .. code-block:: python 13 | 14 | for index, (value1, value2, value3, value4) in enumerate(zip(iterable1, 15 | iterable2, iterable3, iterable4)): 16 | pass 17 | 18 | doctest code: 19 | 20 | >>> 4*10 21 | 40 22 | >>> a=1;print("abc:",a) 23 | abc: 1 24 | >>> # comment 25 | ... a=1 26 | >>> @decorator 27 | ... def f(): 28 | ... pass 29 | 30 | in a code block: 31 | 32 | .. code:: python 33 | 34 | >>> ', '.join( ['15','30'] ) 35 | 15, 30 36 | 37 | executed code: 38 | 39 | .. ipython:: python 40 | 41 | keys = ('key1', 'key2') 42 | values = (15,4) 43 | 44 | mapping = dict(zip(keys, values)) 45 | 46 | mapping 47 | 48 | with explicit grouping: 49 | 50 | .. ipython:: python 51 | 52 | In [1]: keys = ( 'key1', 'key2' ) 53 | ...: values = {15, 4} 54 | 55 | In [2]: mapping = {key : value for key, value in zip( keys,values) } 56 | ...: mapping 57 | 58 | with testcode: 59 | 60 | .. testsetup:: 61 | 62 | x = 'X' 63 | y = "Y" 64 | 65 | .. testcode:: 66 | 67 | assert x==x 68 | assert x != y 69 | 70 | .. testcleanup:: 71 | 72 | print('test completed') 73 | -------------------------------------------------------------------------------- /doc/directory/reformatted.py: -------------------------------------------------------------------------------- 1 | # directory/file.py 2 | """ 3 | docstring with code. 4 | 5 | doctests: 6 | >>> s = { 7 | ... "a", 8 | ... "b", 9 | ... "c", 10 | ... "d", 11 | ... "e", 12 | ... "f", 13 | ... "e", 14 | ... "f", 15 | ... "g", 16 | ... "h", 17 | ... "i", 18 | ... "j", 19 | ... "k", 20 | ... "l", 21 | ... "m", 22 | ... } 23 | 24 | >>> s = "a" + "b" 25 | 26 | >>> def f(): 27 | ... '''nested docstring 28 | ... 29 | ... parameter documentation 30 | ... ''' 31 | ... 32 | ... s = '''triple-quoted string''' 33 | ... 34 | 35 | ipython: 36 | In [1]: d = {"a": 0, "b": 1, "c": 2, "d": 3, "e": 4, "f": 5, "g": 6, "h": 7, "i": 8, "j": 9} 37 | """ 38 | -------------------------------------------------------------------------------- /doc/directory/reformatted.rst: -------------------------------------------------------------------------------- 1 | .. directory/file.rst 2 | file with code: 3 | 4 | .. code:: python 5 | 6 | def function_with_long_name( 7 | parameter1, parameter2, parameter3, *variable_args, **keyword_args 8 | ): 9 | pass 10 | 11 | more code: 12 | 13 | .. code-block:: python 14 | 15 | for index, (value1, value2, value3, value4) in enumerate( 16 | zip(iterable1, iterable2, iterable3, iterable4) 17 | ): 18 | pass 19 | 20 | doctest code: 21 | 22 | >>> 4 * 10 23 | 40 24 | >>> a = 1 25 | >>> print("abc:", a) 26 | abc: 1 27 | 28 | in a code block: 29 | 30 | .. code:: python 31 | 32 | >>> ", ".join(["15", "30"]) 33 | 15, 30 34 | 35 | executed code: 36 | 37 | .. ipython:: python 38 | 39 | keys = ("key1", "key2") 40 | values = (15, 4) 41 | 42 | mapping = dict(zip(keys, values)) 43 | 44 | mapping 45 | 46 | with explicit grouping: 47 | 48 | .. ipython:: python 49 | 50 | In [1]: keys = ("key1", "key2") 51 | ...: values = {15, 4} 52 | 53 | In [2]: mapping = {key: value for key, value in zip(keys, values)} 54 | ...: mapping 55 | 56 | with testcode: 57 | 58 | .. testsetup:: 59 | 60 | x = "X" 61 | y = "Y" 62 | 63 | .. testcode:: 64 | 65 | assert x == x 66 | assert x != y 67 | 68 | .. testcleanup:: 69 | 70 | print("test completed") 71 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | blackdoc: apply black to code in documentation 2 | ============================================== 3 | 4 | **blackdoc** extracts code from documentation, applies **black** to it 5 | and, in reformatting mode, writes the formatted code back to the file. 6 | 7 | The currently supported formats are: 8 | 9 | - doctest 10 | - ipython 11 | - rst 12 | 13 | Documentation 14 | ------------- 15 | **User Guide** 16 | 17 | * :doc:`installing` 18 | * :doc:`usage` 19 | * :doc:`options` 20 | 21 | 22 | .. toctree:: 23 | :maxdepth: 1 24 | :caption: User Guide 25 | :hidden: 26 | 27 | installing 28 | usage 29 | options 30 | 31 | **Help & Reference** 32 | 33 | * :doc:`changelog` 34 | * :doc:`contributing` 35 | 36 | 37 | .. toctree:: 38 | :maxdepth: 1 39 | :caption: Help & Reference 40 | :hidden: 41 | 42 | changelog 43 | contributing 44 | -------------------------------------------------------------------------------- /doc/installing.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ------------ 3 | Its dependencies are: 4 | 5 | - `black`_ 6 | - `more-itertools`_ 7 | - `rich`_ 8 | - `tomli`_ 9 | - `pathspec`_ 10 | 11 | 12 | To install it, use 13 | 14 | .. code:: bash 15 | 16 | python -m pip install blackdoc 17 | 18 | or with ``conda``: 19 | 20 | .. code:: bash 21 | 22 | conda install -c conda-forge blackdoc 23 | 24 | 25 | .. _more-itertools: https://more-itertools.readthedocs.io/ 26 | .. _black: https://black.readthedocs.io/en/stable/ 27 | .. _rich: https://rich.readthedocs.io/en/latest/ 28 | .. _tomli: https://github.com/hukkin/tomli 29 | .. _pathspec: https://python-path-specification.readthedocs.io/en/latest/ 30 | -------------------------------------------------------------------------------- /doc/options.rst: -------------------------------------------------------------------------------- 1 | Options 2 | ======= 3 | Since it builds on ``black``, ``blackdoc`` supports most options provided by 4 | ``black``, in addition to selecting the available formats. 5 | 6 | Options can be set using either command line options or a configuration file in 7 | ``toml`` format (by default, ``pyproject.toml``). 8 | 9 | General options 10 | --------------- 11 | These are command line-only options. 12 | 13 | custom configuration file 14 | Using the ``--config`` option, a arbitrary file in ``toml`` format can be 15 | specified to use instead of ``pyproject.toml``. 16 | 17 | check 18 | With ``--check``, the program will not write back to disk, but instead 19 | return ``0`` if no file would be changed, ``1`` if a file would be 20 | reformatted and ``123`` if an internal error occurred. 21 | 22 | diff 23 | In addition to the behavior of ``--check``, ``--diff`` will output a unified diff of 24 | the changes that would have been made. 25 | 26 | color / no-color 27 | Has no effect without ``--diff``. If enabled, the unified diffs will be colored. 28 | 29 | version 30 | Print the version and exit. 31 | 32 | ``black`` 33 | --------- 34 | target_versions 35 | ``-t`` or ``--target-versions``, ``str``. A comma-separated string of python versions 36 | (format: ``pyXY``, e.g. ``py38``). By default, the version is auto-detected 37 | per file. 38 | 39 | line_length 40 | ``-l`` or ``--line-length``, ``int``. How many characters per line to allow. By 41 | default, set to 88. 42 | 43 | skip_string_normalization 44 | ``-S`` or ``--skip-string-normalization``. If enabled, skips the string normalization. 45 | 46 | include 47 | ``--include``, ``str``. A regular expression that matches files and 48 | directories that should be included on recursive searches. An empty value 49 | means all files are included regardless of the name. Use forward slashes for 50 | directories on all platforms (Windows, too). Exclusions are calculated 51 | first, inclusions later. By default, set to ``(\.pyi?$|\.rst$)`` 52 | 53 | exclude 54 | ``--exclude``, ``str``. A regular expression that matches files and 55 | directories that should be excluded on recursive searches. An empty value 56 | means no paths are excluded. Use forward slashes for directories on all 57 | platforms (Windows, too). Exclusions are calculated first, inclusions 58 | later. By default, set to 59 | ``/(\.direnv|\.eggs|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|\.svn|_build|buck-out|build|dist)/``. 60 | 61 | force_exclude 62 | ``--force-exclude``, ``str``. Like ``--exclude``, but files and directories 63 | matching this regex will be excluded even when they are passed explicitly as 64 | arguments. By default, set to ``""``. 65 | 66 | ``blackdoc`` 67 | ------------ 68 | formats 69 | ``--formats``, ``str``. A comma-separated string of formats to use. By 70 | default, all formats are used. 71 | 72 | disable_formats 73 | ``--disable-formats``, ``str``. A comma-separated string of formats not to 74 | use. This affects even formats that were explicitly enabled. By default, no 75 | format is disabled. 76 | -------------------------------------------------------------------------------- /doc/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | command-line interface 4 | ---------------------- 5 | **blackdoc** tries to copy as much of the CLI of **black** as 6 | possible. This means that most calls to **black** can be directly 7 | translated to **blackdoc**: 8 | 9 | To reformat specific files, use: 10 | 11 | .. code:: sh 12 | 13 | python -m blackdoc file1 file2 ... 14 | 15 | while passing directories reformats all files in those directories 16 | that match the file format specified by ``--include`` and 17 | ``--exclude``: 18 | 19 | .. code:: sh 20 | 21 | python -m blackdoc directory1 directory2 ... 22 | 23 | mixing directories and files is also possible. 24 | 25 | As an example, having a structure like:: 26 | 27 | directory 28 | ├── file.py 29 | └── file.rst 30 | 31 | with 32 | 33 | .. literalinclude:: directory/file.py 34 | 35 | .. literalinclude:: directory/file.rst 36 | :language: rst 37 | 38 | If we run 39 | 40 | .. code:: sh 41 | 42 | python -m blackdoc directory 43 | 44 | we get 45 | 46 | .. literalinclude:: directory/reformatted.py 47 | 48 | .. literalinclude:: directory/reformatted.rst 49 | :language: rst 50 | 51 | If instead we run 52 | 53 | .. code:: sh 54 | 55 | python -m blackdoc --check directory 56 | 57 | the result is:: 58 | 59 | would reformat directory/file.rst 60 | would reformat directory/file.py 61 | Oh no! 💥 💔 💥 62 | 2 files would be reformatted. 63 | 64 | with a non-zero exit status. 65 | 66 | pre-commit 67 | ---------- 68 | The repository also defines a ``pre-commit`` hook: 69 | 70 | .. code:: yaml 71 | 72 | hooks: 73 | ... 74 | - repo: https://github.com/keewis/blackdoc 75 | rev: 3.8.0 76 | hooks: 77 | - id: blackdoc 78 | 79 | It is recommended to *pin* ``black`` in order to avoid having different versions for each contributor. To automatically synchronize this pin with the version of the ``black`` hook, use the ``blackdoc-autoupdate-black`` hook: 80 | 81 | .. code:: yaml 82 | 83 | hooks: 84 | ... 85 | - repo: https://github.com/psf/black 86 | rev: 23.10.1 87 | hooks: 88 | - id: black 89 | ... 90 | - repo: https://github.com/keewis/blackdoc 91 | rev: 3.8.0 92 | hooks: 93 | - id: blackdoc 94 | additional_dependencies: ["black==23.10.1"] 95 | - id: blackdoc-autoupdate-black 96 | 97 | Note that this hook is *not* run on ``pre-commit autoupdate``. 98 | -------------------------------------------------------------------------------- /licenses/BLACK_LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Łukasz Langa 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. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61", "wheel", "setuptools_scm[toml]>=7.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools] 6 | packages = [ 7 | "blackdoc", 8 | "blackdoc.formats", 9 | "blackdoc.tests", 10 | "blackdoc.tests.data", 11 | ] 12 | 13 | [tool.setuptools_scm] 14 | 15 | [project] 16 | name = "blackdoc" 17 | authors = [ 18 | { name = "Justus Magin", email = "keewis@posteo.de" }, 19 | ] 20 | license = { text = "MIT License" } 21 | description = "run black on documentation code snippets" 22 | readme = "README.rst" 23 | classifiers = [ 24 | "Development Status :: 3 - Alpha", 25 | "Environment :: Console", 26 | "Intended Audience :: Developers", 27 | "License :: OSI Approved :: MIT License", 28 | "Operating System :: OS Independent", 29 | "Programming Language :: Python", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Programming Language :: Python :: 3.13", 35 | "Programming Language :: Python :: 3 :: Only", 36 | "Topic :: Documentation", 37 | "Topic :: Software Development :: Documentation", 38 | "Topic :: Software Development :: Libraries :: Python Modules", 39 | "Topic :: Software Development :: Quality Assurance", 40 | ] 41 | requires-python = ">= 3.9" 42 | dependencies = [ 43 | "black", 44 | "more-itertools", 45 | "tomli", 46 | "pathspec", 47 | "rich", 48 | ] 49 | dynamic = ["version"] 50 | 51 | 52 | [project.urls] 53 | Repository = "https://github.com/keewis/blackdoc" 54 | Issues = "https://github.com/keewis/blackdoc/issues" 55 | Documentation = "https://blackdoc.readthedocs.io/en/stable" 56 | 57 | [project.scripts] 58 | blackdoc = "blackdoc.__main__:main" 59 | 60 | [tool.ruff] 61 | target-version = "py39" 62 | builtins = ["ellipsis"] 63 | exclude = [ 64 | ".git", 65 | ".eggs", 66 | "build", 67 | "dist", 68 | "__pycache__", 69 | ] 70 | line-length = 100 71 | 72 | [tool.ruff.lint] 73 | ignore = [ 74 | "E402", # E402: module level import not at top of file 75 | "E501", # E501: line too long - let black worry about that 76 | "E731", # E731: do not assign a lambda expression, use a def 77 | ] 78 | select = [ 79 | "F", # Pyflakes 80 | "E", # Pycodestyle 81 | "I", # isort 82 | "UP", # Pyupgrade 83 | "TID", # flake8-tidy-imports 84 | "W", 85 | ] 86 | extend-safe-fixes = [ 87 | "TID252", # absolute imports 88 | ] 89 | fixable = ["I", "TID252", "UP"] 90 | 91 | [tool.ruff.lint.isort] 92 | known-first-party = ["blackdoc"] 93 | 94 | [tool.ruff.lint.flake8-tidy-imports] 95 | # Disallow all relative imports. 96 | ban-relative-imports = "all" 97 | --------------------------------------------------------------------------------