├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ ├── developer_experience.yaml │ └── feature_request.yaml ├── pull_request_template.md └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json ├── python.code-snippets └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── cspell.json ├── mkdocs.yml ├── pyproject.toml ├── pyprojectsort ├── __init__.py ├── __main__.py ├── __version__.py └── main.py ├── tests ├── test_main.py ├── test_pyprojectsort.py └── test_version.py └── uv.lock /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve 3 | labels: [":bug: bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: > 8 | Thanks for taking the time to fill out this bug report! 9 | - type: textarea 10 | id: what-happened 11 | attributes: 12 | label: 👓 What did you see? 13 | description: A clear and concise description of what you saw happen. 14 | placeholder: Tell us what you see! 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: what-was-expected 19 | attributes: 20 | label: ✅ What did you expect to see? 21 | description: Describe what you would like to have happen instead. 22 | placeholder: Tell us what you expected to see! 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: version 27 | attributes: 28 | label: 📦 Which package version are you using? 29 | description: What version of our software are you running? 30 | placeholder: | 31 | python 3.12.0 32 | pyprojectsort 0.4.0 33 | - type: textarea 34 | id: how-to-reproduce 35 | attributes: 36 | label: 🔬 How could we reproduce it? 37 | description: > 38 | It order to fix the problem, we need to be able to reproduce it. 39 | A [Minimal Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example) can be really helpful for anyone 40 | trying to diagnose and fix the problem. 41 | 42 | 43 | Please outline the steps below: 44 | placeholder: | 45 | 1. Install '...' version '...' 46 | 2. Create a file called '....' 47 | 3. Run command '....' 48 | 4. See error '....' 49 | - type: textarea 50 | id: context 51 | attributes: 52 | label: 📚 Any additional context? 53 | description: Add any other context, references, logs or screenshots about the problem here. 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/developer_experience.yaml: -------------------------------------------------------------------------------- 1 | name: Developer Experience 2 | description: > 3 | Refactoring or technical debt payback that makes the codebase more 4 | pleasant to work on 5 | labels: [":bank: debt"] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: Thanks for suggesting an improvement to the code! 10 | - type: textarea 11 | id: problem 12 | attributes: 13 | label: 🤔 What's the problem you've observed? 14 | placeholder: Add your observations here... 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: proposal 19 | attributes: 20 | label: ✨ Do you have a proposal for making it better? 21 | placeholder: Add your suggestions here... 22 | - type: textarea 23 | id: context 24 | attributes: 25 | label: 📚 Any additional context? 26 | placeholder: > 27 | Add any other context, references or screenshots here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | labels: [":zap: enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: > 8 | Thanks for taking the time to suggest an idea for this project! 9 | - type: textarea 10 | id: problem 11 | attributes: 12 | label: 🤔 What's the problem you're trying to solve? 13 | description: > 14 | A clear and concise description of what the problem is e.g. I'm 15 | always frustrated when ... 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: proposal 20 | attributes: 21 | label: ✨ What's your proposed solution? 22 | description: A clear and concise description of what you want to happen. 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: alternatives 27 | attributes: 28 | label: ⛏ Have you considered any alternatives or workarounds? 29 | description: > 30 | A clear and concise description of any alternative solutions or features 31 | you've considered. 32 | - type: textarea 33 | id: context 34 | attributes: 35 | label: 📚 Any additional context? 36 | description: > 37 | Add any other context, references or screenshots about the feature 38 | request here. 39 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### 🤔 What's changed? 2 | 3 | 4 | 5 | ### ⚡️ What's your motivation? 6 | 7 | 11 | 12 | ### 🏷️ What kind of change is this? 13 | 14 | 15 | 16 | - :book: Documentation (improvements without changing code) 17 | - :bank: Refactoring/debt/DX (improvement to code design, tooling, etc. without changing behaviour) 18 | - :bug: Bug fix (non-breaking change which fixes a defect) 19 | - :zap: New feature (non-breaking change which adds new behaviour) 20 | - :boom: Breaking change (incompatible changes to the API) 21 | 22 | ### ♻️ Anything particular you want feedback on? 23 | 24 | 28 | 29 | ### 📋 Checklist: 30 | 31 | - [ ] I've changed the behaviour of the code 32 | - [ ] I have added/updated tests to cover my changes. 33 | - [ ] My change requires a change to the documentation. 34 | - [ ] I have updated the documentation accordingly. 35 | - [ ] Users should know about my change 36 | - [ ] I have added an entry to the "Unreleased" section of the [**CHANGELOG**](./../CHANGELOG.md), linking to this pull request. 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | run-tests: 7 | strategy: 8 | matrix: 9 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 10 | os: [macos-latest, windows-latest, ubuntu-latest] 11 | 12 | runs-on: ${{ matrix.os }} 13 | name: Test with Python ${{ matrix.python-version }} on ${{ matrix.os }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Install uv and set the Python version as ${{ matrix.python-version }} 18 | uses: astral-sh/setup-uv@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Run tests 23 | run: uv run pytest 24 | 25 | - name: Upload coverage reports to Codecov 26 | uses: codecov/codecov-action@v5 27 | with: 28 | files: .reports/coverage.xml 29 | token: ${{ secrets.CODECOV_TOKEN }} 30 | 31 | - name: Upload test results to Codecov 32 | if: ${{ !cancelled() }} 33 | uses: codecov/test-results-action@v1 34 | with: 35 | files: .reports/junit.xml 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | 38 | build-docs: 39 | name: Build documentation 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - name: Install uv and set the Python version 45 | uses: astral-sh/setup-uv@v5 46 | with: 47 | python-version: "3.10" 48 | 49 | - name: Build documentation 50 | run: uv run mkdocs build 51 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | name: Build package 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Install uv and set the Python version 15 | uses: astral-sh/setup-uv@v5 16 | 17 | - name: Build distribution 18 | run: uv build 19 | 20 | - name: Upload artifact 21 | id: artifact-upload-step 22 | uses: actions/upload-artifact@v4 23 | with: 24 | name: distribution 25 | path: dist/* 26 | if-no-files-found: error 27 | compression-level: 0 # They are already compressed 28 | 29 | publish-pypi: 30 | name: Publish package to ${{ matrix.environment.name }} 31 | runs-on: ubuntu-latest 32 | environment: 33 | name: pypi 34 | url: https://pypi.org/project/pyprojectsort 35 | permissions: 36 | id-token: write 37 | steps: 38 | - name: Download artifact 39 | uses: actions/download-artifact@v4 40 | with: 41 | name: distribution 42 | path: dist/ 43 | 44 | - name: Publish distribution to PyPI 45 | run: uv publish 46 | 47 | publish-docs: 48 | name: Build documentation and deploy to GitHub Pages 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v4 52 | 53 | - name: Install uv and set the Python version 54 | uses: astral-sh/setup-uv@v5 55 | 56 | - name: Build documentation 57 | run: uv run mkdocs build 58 | 59 | - name: Publish documentation to GitHub Pages 60 | uses: peaceiris/actions-gh-pages@v3 61 | with: 62 | publish_branch: gh-pages 63 | github_token: ${{ secrets.GITHUB_TOKEN }} 64 | publish_dir: site 65 | force_orphan: true 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | *.d 3 | 4 | # Compiled Object files 5 | *.slo 6 | *.lo 7 | *.o 8 | *.obj 9 | 10 | # Precompiled Headers 11 | *.gch 12 | *.pch 13 | 14 | # Compiled Dynamic libraries 15 | *.dylib 16 | *.dll 17 | 18 | # Fortrain module files 19 | *.mod 20 | *.smod 21 | 22 | # Compiled Static libraries 23 | *.lai 24 | *.la 25 | *.a 26 | *.lib 27 | 28 | # Executables 29 | *.exe 30 | *.out 31 | *.app 32 | 33 | # Byte-compiled / optimized / DLL files 34 | __pycache__/ 35 | *.py[cod] 36 | *$py.class 37 | 38 | # C extensions 39 | *.so 40 | 41 | # Distribution / packaging 42 | .Python 43 | build/ 44 | develop-eggs/ 45 | dist/ 46 | downloads/ 47 | eggs/ 48 | .eggs/ 49 | lib/ 50 | lib64/ 51 | parts/ 52 | sdist/ 53 | var/ 54 | wheels/ 55 | pip-wheel-metadata/ 56 | share/python-wheels/ 57 | *.egg-info/ 58 | .installed.cfg 59 | *.egg 60 | MANIFEST 61 | 62 | # PyInstaller 63 | # Usually these files are written by a python script from a template 64 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 65 | *.manifest 66 | *.spec 67 | 68 | # Installer logs 69 | pip-log.txt 70 | pip-delete-this-directory.txt 71 | 72 | # Unit test / coverage reports 73 | htmlcov/ 74 | .tox/ 75 | .nox/ 76 | .coverage 77 | .coverage.* 78 | .cache 79 | nosetests.xml 80 | coverage.xml 81 | *.cover 82 | *.py,cover 83 | .hypothesis/ 84 | .pytest_cache/ 85 | junit.xml 86 | .reports/ 87 | docs/ 88 | 89 | # Ruff linting - for IDE highlighting - as internals already ignored 90 | .ruff_cache/ 91 | 92 | # Node modules - for IDE highlighting - as internals already ignored 93 | node_modules/ 94 | 95 | # Translations 96 | *.mo 97 | *.pot 98 | 99 | # Django stuff: 100 | *.log 101 | local_settings.py 102 | db.sqlite3 103 | db.sqlite3-journal 104 | 105 | # Flask stuff: 106 | instance/ 107 | .webassets-cache 108 | 109 | # Scrapy stuff: 110 | .scrapy 111 | 112 | # Sphinx documentation 113 | docs/_build/ 114 | 115 | # PyBuilder 116 | target/ 117 | 118 | # Jupyter Notebook 119 | .ipynb_checkpoints 120 | 121 | # IPython 122 | profile_default/ 123 | ipython_config.py 124 | 125 | # pyenv 126 | # For a library or package, you might want to ignore these files since the code is 127 | # intended to run in multiple environments; otherwise, check them in: 128 | .python-version 129 | 130 | # pipenv 131 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 132 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 133 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 134 | # install all needed dependencies. 135 | # Pipfile.lock 136 | 137 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 138 | __pypackages__/ 139 | 140 | # Celery stuff 141 | celerybeat-schedule 142 | celerybeat.pid 143 | 144 | # SageMath parsed files 145 | *.sage.py 146 | 147 | # Environments 148 | .env 149 | .venv 150 | env/ 151 | venv/ 152 | ENV/ 153 | env.bak/ 154 | venv.bak/ 155 | 156 | # Spyder project settings 157 | .spyderproject 158 | .spyproject 159 | 160 | # Rope project settings 161 | .ropeproject 162 | 163 | # mkdocs documentation 164 | /site 165 | 166 | # mypy 167 | .mypy_cache/ 168 | .dmypy.json 169 | dmypy.json 170 | 171 | # Pyre type checker 172 | .pyre/ 173 | 174 | # Cython debug symbols 175 | cython_debug/ 176 | 177 | ### Linux 178 | *~ 179 | 180 | # temporary files which can be created if a process still has a handle open of a deleted file 181 | .fuse_hidden* 182 | 183 | # KDE directory preferences 184 | .directory 185 | 186 | # Linux trash folder which might appear on any partition or disk 187 | .Trash-* 188 | 189 | # .nfs files are created when an open file is removed but is still being accessed 190 | .nfs* 191 | 192 | ### macOS 193 | # General 194 | .DS_Store 195 | .AppleDouble 196 | .LSOverride 197 | 198 | # Icon myst end with two \r 199 | Icon 200 | 201 | # Thumbnails 202 | ._* 203 | 204 | # Files that might appear in the root of a volume 205 | .DocumentRevisions-V100 206 | .fseventsd 207 | .Spotlight-V100 208 | .TemporaryItems 209 | .Trashes 210 | .VolumeIcons.icns 211 | .com.apple.timemachine.donotpresent 212 | 213 | # Directories potentilly created on remote FP share 214 | .AppleDB 215 | .AppleDesktop 216 | Network Trash Folder 217 | Temporary Items 218 | .apdisk 219 | 220 | ### Visual Studio Code 221 | .vscode/* 222 | !.vscode/settings.json 223 | !.vscode/tasks.json 224 | !.vscode/launch.json 225 | !.vscode/extensions.json 226 | !*.code-snippets 227 | *.code-workspace 228 | 229 | # Local History for Visual Studio Code 230 | .history/ 231 | 232 | ### JetBrains 233 | # Covers JetBrains IDEs: IntelliJ, Rubymine, PhpStorm, AppCode, PyCharm, CLion, 234 | # AndroidStudio, WebStorm and Rider 235 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 236 | 237 | # User-specific stuff 238 | .idea/ 239 | 240 | # CMake 241 | cmake-build-*/ 242 | 243 | # File-based project format 244 | *.iws 245 | 246 | # mpeltonen/sbt-idea plugin 247 | .idea_modules/ 248 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-case-conflict 7 | - id: check-docstring-first 8 | - id: check-json 9 | - id: check-yaml 10 | - id: check-merge-conflict 11 | - id: debug-statements 12 | - id: end-of-file-fixer 13 | - id: requirements-txt-fixer 14 | - id: trailing-whitespace 15 | - repo: https://github.com/kieran-ryan/pyprojectsort 16 | rev: v0.4.0 17 | hooks: 18 | - id: pyprojectsort 19 | - repo: https://github.com/asmeurer/removestar 20 | rev: "1.5.2" 21 | hooks: 22 | - id: removestar 23 | args: [--in-place, pyprojectsort] 24 | - repo: https://github.com/astral-sh/ruff-pre-commit 25 | rev: "v0.9.10" 26 | hooks: 27 | - id: ruff 28 | args: [--fix] 29 | - id: ruff-format 30 | - repo: https://github.com/streetsidesoftware/cspell-cli 31 | rev: v8.17.3 32 | hooks: 33 | - id: cspell 34 | - repo: https://github.com/pre-commit/mirrors-prettier 35 | rev: "v4.0.0-alpha.8" 36 | hooks: 37 | - id: prettier 38 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: pyprojectsort 2 | name: pyprojectsort 3 | description: "`pyprojectsort` is a formatter for pyproject.toml files" 4 | entry: pyprojectsort 5 | language: python 6 | types: [toml] 7 | args: [] 8 | pass_filenames: false 9 | require_serial: true 10 | additional_dependencies: [] 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "overrides": [ 4 | { 5 | "files": "*.md", 6 | "options": { 7 | "tabWidth": 4 8 | } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "esbenp.prettier-vscode", 5 | "ms-python.python", 6 | "streetsidesoftware.code-spell-checker-british-english", 7 | "redhat.vscode-yaml" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.1", 3 | "configurations": [ 4 | { 5 | "name": "Python: Debug Tests", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "program": "${file}", 9 | "purpose": ["debug-test"], 10 | "console": "integratedTerminal", 11 | "justMyCode": false, 12 | "env": { 13 | "PYTEST_ADDOPTS": "--no-cov -n0 --dist no" 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/python.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "if(main)": { 3 | "prefix": "__main__", 4 | "body": ["if __name__ == \"__main__\":", " ${1:pass}"], 5 | "description": "Code snippet for a `if __name__ == \"__main__\": ...` block", 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.formatOnPaste": true, 4 | "files.trimTrailingWhitespace": true, 5 | "git.autofetch": true, 6 | "[jsonc]": { 7 | "editor.defaultFormatter": "vscode.json-language-features", 8 | "files.insertFinalNewline": true 9 | }, 10 | "[python]": { 11 | "editor.defaultFormatter": "charliermarsh.ruff" 12 | }, 13 | "python.testing.unittestEnabled": false, 14 | "python.testing.pytestEnabled": true, 15 | "yaml.customTags": [ 16 | "!ENV scalar", 17 | "!ENV sequence", 18 | "!relative scalar", 19 | "tag:yaml.org,2002:python/name:material.extensions.emoji.to_svg", 20 | "tag:yaml.org,2002:python/name:material.extensions.emoji.twemoji", 21 | "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format", 22 | "tag:yaml.org,2002:python/object/apply:pymdownx.slugs.slugify mapping" 23 | ], 24 | "yaml.schemas": { 25 | "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/). 7 | 8 | ## Unreleased 9 | 10 | ### Changed 11 | 12 | - Drop support for end-of-life Python 3.7 13 | 14 | ## 0.4.0 - 2024-12-31 15 | 16 | ### Added 17 | 18 | - Python 3.12 and 3.13 support - [#70](https://github.com/kieran-ryan/pyprojectsort/pull/70) 19 | 20 | ### Fixed 21 | 22 | - Allow tomli-w above v1.0.0 - [#75](https://github.com/kieran-ryan/pyprojectsort/pull/75) 23 | 24 | ## 0.3.0 - 2023-07-18 25 | 26 | ### Added 27 | 28 | - Command line option to render diff of changes - [#16](https://github.com/kieran-ryan/pyprojectsort/issues/16) 29 | - Official support for Python 3.7 to 3.11 - [#14](https://github.com/kieran-ryan/pyprojectsort/issues/14) 30 | 31 | ### Changed 32 | 33 | - Format mixed data types in an array - [#39](https://github.com/kieran-ryan/pyprojectsort/issues/39) 34 | - Natural sort of string based numbers - [#52](https://github.com/kieran-ryan/pyprojectsort/pull/52) 35 | 36 | ## 0.2.2 - 2023-07-08 37 | 38 | ### Added 39 | 40 | - Pre-commit git hook support - [#13](https://github.com/kieran-ryan/pyprojectsort/issues/13) 41 | 42 | ### Fixes 43 | 44 | - Write changes when values are the same but formatting required - [#34](https://github.com/kieran-ryan/pyprojectsort/issues/34) 45 | 46 | ## 0.2.1 - 2023-07-05 47 | 48 | ### Deprecated 49 | 50 | - Key normalisation, which can affect tools that expect a particular format - [#27](https://github.com/kieran-ryan/pyprojectsort/issues/27) 51 | 52 | ## 0.2.0 - 2023-07-05 53 | 54 | ### Added 55 | 56 | - Support to check whether file would be reformatted without writing changes - [#10](https://github.com/kieran-ryan/pyprojectsort/issues/10) 57 | - Support to specify the pyproject.toml path - [#9](https://github.com/kieran-ryan/pyprojectsort/issues/9) 58 | 59 | ### Changes 60 | 61 | - Alphabetically sort section keys - [#5](https://github.com/kieran-ryan/pyprojectsort/issues/5) 62 | - Alphabetically sort list key values - [#7](https://github.com/kieran-ryan/pyprojectsort/issues/7) 63 | 64 | ### Fixes 65 | 66 | - Writes to pyproject.toml only if there are changes to made - [#19](https://github.com/kieran-ryan/pyprojectsort/pull/19) 67 | 68 | ## 0.1.1 - 2023-06-26 69 | 70 | ### Changes 71 | 72 | - Alphabetically sort pyproject.toml files by parent section name 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kieran Ryan 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.md: -------------------------------------------------------------------------------- 1 |

pyprojectsort

2 | 3 |

4 | Formatter for pyproject.toml files 5 |

6 | 7 | [![PyPI Version](https://badge.fury.io/py/pyprojectsort.svg)](https://pypi.org/project/pyprojectsort/) 8 | ![LICENSE](https://img.shields.io/badge/license-MIT-blue) 9 | [![Python versions](https://img.shields.io/pypi/pyversions/pyprojectsort.svg)](https://pypi.org/pypi/pyprojectsort) 10 | ![Supported platforms](https://img.shields.io/badge/platforms-macOS%20%7C%20Windows%20%7C%20Linux-green) 11 | ![Pipeline status](https://github.com/kieran-ryan/python-package-template/actions/workflows/main.yml/badge.svg) 12 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/kieran-ryan/pyprojectsort/main.svg)](https://results.pre-commit.ci/latest/github/kieran-ryan/pyprojectsort/main) 13 | [![codecov](https://codecov.io/gh/kieran-ryan/pyprojectsort/graph/badge.svg?token=MNGM4NAXEB)](https://codecov.io/gh/kieran-ryan/pyprojectsort) 14 | 15 | This package enforces consistent formatting of pyproject.toml files, reducing merge request conflicts and saving time otherwise spent on manual formatting. It also contributes to a cleaner git history and more readable code; enhancing overall project organisation and maintainability. Experience a streamlined workflow, reduced errors, and improved code readability with `pyprojectsort`. 16 | 17 | ## Features 18 | 19 | - Alphanumerically sorts pyproject.toml by: 20 | - section 21 | - section key 22 | - list value 23 | - Reformats pyproject.toml to a standardised style 24 | - line per list value 25 | - double quotations 26 | - trailing commas 27 | - indentation 28 | - end of file newline 29 | 30 | ## Installation 31 | 32 | `pyprojectsort` is available via [PyPI](https://pypi.org/project/pyprojectsort/): 33 | 34 | ```console 35 | pip install pyprojectsort 36 | ``` 37 | 38 | ### Using pyprojectsort with [pre-commit](https://pre-commit.com) 39 | 40 | To use as an automated git hook, add this to your `.pre-commit-config.yaml`: 41 | 42 | ```yaml 43 | - repo: https://github.com/kieran-ryan/pyprojectsort 44 | rev: v0.4.0 45 | hooks: 46 | - id: pyprojectsort 47 | ``` 48 | 49 | ## Examples 50 | 51 | With the following `pyproject.toml`: 52 | 53 | ```toml 54 | [tool.ruff] 55 | ignore = ["G004", 56 | "T201", 57 | "ANN" 58 | ] 59 | 60 | [project] 61 | name = 'pyprojectsort' 62 | authors = [ 63 | { name = "Kieran Ryan" }, 64 | "Author Name ", 65 | {name="Author Name"} 66 | ] 67 | 68 | [tool.radon] 69 | show_mi = true 70 | exclude = "tests/*,venv/*" 71 | total_average = true 72 | show_complexity = true 73 | 74 | [build-system] 75 | build-backend = "flit.buildapi" 76 | requires = ["flit"] 77 | ``` 78 | 79 | Run the package from within its directory: 80 | 81 | ```console 82 | pyprojectsort 83 | ``` 84 | 85 | The configuration will be reformatted as follows: 86 | 87 | ```toml 88 | [build-system] 89 | build-backend = "flit.buildapi" 90 | requires = [ 91 | "flit", 92 | ] 93 | 94 | [project] 95 | authors = [ 96 | "Author Name ", 97 | { name = "Author Name" }, 98 | { name = "Kieran Ryan" }, 99 | ] 100 | name = "pyprojectsort" 101 | 102 | [tool.radon] 103 | exclude = "tests/*,venv/*" 104 | show_complexity = true 105 | show_mi = true 106 | total_average = true 107 | 108 | [tool.ruff] 109 | ignore = [ 110 | "ANN", 111 | "G004", 112 | "T201", 113 | ] 114 | ``` 115 | 116 | The pyproject file path can alternatively be specified: 117 | 118 | ```console 119 | pyprojectsort ../pyproject.toml 120 | ``` 121 | 122 | ### Check formatting 123 | 124 | The **--check** option can be used to determine whether your file would be reformatted. 125 | 126 | ```console 127 | pyprojectsort --check 128 | ``` 129 | 130 | If the file needs reformatting, the program exits with an error code. This is useful for [pipeline integration](https://github.com/kieran-ryan/pyprojectsort/blob/d9cf5e1e646e1e5260f7cf0168ecd0a05ce8ed11/.github/workflows/main.yml#L30) as it prevents writing back changes so that a clean repository is maintained for subsequent jobs. 131 | 132 | The **--diff** option provides similar functionality but also displays any changes that would be made. 133 | 134 | ```console 135 | pyprojectsort --diff 136 | ``` 137 | 138 | The diff of an alphabetically reordered array will appear as follows: 139 | 140 | ```diff 141 | @@ -6,8 +6,8 @@ 142 | [project] 143 | authors = [ 144 | + { name = "Author Name" }, 145 | { name = "Kieran Ryan" }, 146 | - { name = "Author Name" }, 147 | ] 148 | ``` 149 | 150 | ## Contributing 151 | 152 | Contributions are welcome for `pyprojectsort`, and can be made by raising [issues](https://github.com/kieran-ryan/pyprojectsort/issues) or [pull requests](https://github.com/kieran-ryan/pyprojectsort/pulls). 153 | 154 | Using [`uv`](https://docs.astral.sh/uv/getting-started/installation/#pypi) for package and project management is encouraged when developing with the project - though not required. You will typically want to use the below commands within the project during development. 155 | 156 | | Command | Purpose | 157 | | ------------------------- | ------------------------------------------- | 158 | | uv run pytest | 🧪 Run the tests | 159 | | uv run pre-commit | 🔎 Run the linting checks on staged changes | 160 | | uv run pre-commit install | 🕵️‍♀️ Run the linting checks on commit | 161 | | uv run mkdocs serve | 📄 Build the documentation | 162 | | uv build | 📦 Build the package | 163 | 164 | ## License 165 | 166 | `pyprojectsort` is licensed under the [MIT License](https://opensource.org/licenses/MIT). 167 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en-GB", 4 | "ignorePaths": [ 5 | "node_modules/**", 6 | "**/.gitignore", 7 | "**/.pre-commit-config.yaml", 8 | "**/pyproject.toml", 9 | "**/requirements*.txt" 10 | ], 11 | "words": [ 12 | "ADDOPTS", 13 | "autofetch", 14 | "buildapi", 15 | "charliermarsh", 16 | "codecov", 17 | "debugpy", 18 | "esbenp", 19 | "fontawesome", 20 | "isort", 21 | "mkdocs", 22 | "natsort", 23 | "natsorted", 24 | "peaceiris", 25 | "pymdownx", 26 | "pypa", 27 | "pypi", 28 | "pyproject", 29 | "pyprojectsort", 30 | "pytest", 31 | "superfences", 32 | "tomli", 33 | "venv" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: pyprojectsort 2 | docs_dir: . 3 | site_url: https://kieran-ryan.github.io/pyprojectsort 4 | theme: 5 | name: material 6 | palette: 7 | scheme: slate 8 | 9 | features: 10 | - toc.integrate 11 | - search.suggest 12 | - search.highlight 13 | - content.code.copy 14 | 15 | markdown_extensions: 16 | - pymdownx.snippets 17 | - pymdownx.superfences 18 | 19 | extra: 20 | social: 21 | - icon: fontawesome/brands/github-alt 22 | link: https://github.com/kieran-ryan/pyprojectsort 23 | 24 | plugins: 25 | - same-dir 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "flit_core.buildapi" 3 | requires = [ 4 | "flit_core >=2,<4", 5 | ] 6 | 7 | [dependency-groups] 8 | docs = [ 9 | "mkdocs-material==9.5.49", 10 | "mkdocs-same-dir==0.1.3", 11 | ] 12 | lint = [ 13 | "pre-commit==3.3.3", 14 | ] 15 | test = [ 16 | "packaging==23.1", 17 | "pyprojectsort", 18 | "pytest-cov==4.1.0", 19 | "pytest==7.4.0", 20 | ] 21 | 22 | [project] 23 | authors = [ 24 | { name = "Kieran Ryan" }, 25 | ] 26 | classifiers = [ 27 | "Environment :: Console", 28 | "Intended Audience :: Developers", 29 | "License :: OSI Approved :: MIT License", 30 | "Operating System :: OS Independent", 31 | "Programming Language :: Python :: 3 :: Only", 32 | "Programming Language :: Python :: 3.8", 33 | "Programming Language :: Python :: 3.9", 34 | "Programming Language :: Python :: 3.10", 35 | "Programming Language :: Python :: 3.11", 36 | "Programming Language :: Python :: 3.12", 37 | "Programming Language :: Python :: 3.13", 38 | "Topic :: Software Development :: Quality Assurance", 39 | ] 40 | dependencies = [ 41 | "natsort>=8.4.0,<9", 42 | "tomli-w>=1.0.0", 43 | "tomli>=2.0.1,<3", 44 | ] 45 | description = "Formatter for pyproject.toml files" 46 | dynamic = [ 47 | "version", 48 | ] 49 | keywords = [ 50 | "formatter", 51 | "pyproject", 52 | ] 53 | name = "pyprojectsort" 54 | readme = "README.md" 55 | requires-python = ">=3.8" 56 | 57 | [project.license] 58 | file = "LICENSE" 59 | 60 | [project.scripts] 61 | pyprojectsort = "pyprojectsort.main:main" 62 | 63 | [project.urls] 64 | Changelog = "https://github.com/kieran-ryan/pyprojectsort/blob/main/CHANGELOG.md" 65 | Documentation = "https://kieran-ryan.github.io/pyprojectsort" 66 | Source = "https://github.com/kieran-ryan/pyprojectsort" 67 | Tracker = "https://github.com/kieran-ryan/pyprojectsort/issues" 68 | 69 | [tool.coverage.html] 70 | directory = ".reports/coverage" 71 | show_contexts = true 72 | 73 | [tool.coverage.report] 74 | exclude_lines = [ 75 | "if __name__ == \"__main__\":", 76 | "if typing.TYPE_CHECKING:", 77 | ] 78 | fail_under = 75.0 79 | show_missing = true 80 | 81 | [tool.coverage.run] 82 | branch = true 83 | omit = [ 84 | "*/tests/*", 85 | "*/venv/*", 86 | ] 87 | 88 | [tool.coverage.xml] 89 | output = ".reports/coverage.xml" 90 | 91 | [tool.flit.module] 92 | name = "pyprojectsort" 93 | 94 | [tool.pytest.ini_options] 95 | addopts = "--doctest-modules -rA --verbose --junitxml=.reports/junit.xml -o junit_family=legacy --cov-report=term --cov-report=html --cov-report=xml --cov=pyprojectsort" 96 | testpaths = [ 97 | "pyprojectsort", 98 | "tests", 99 | ] 100 | 101 | [tool.ruff] 102 | target-version = "py38" 103 | 104 | [tool.ruff.lint] 105 | ignore = [ 106 | "ANN", 107 | "ARG", 108 | "COM812", 109 | "D203", 110 | "D213", 111 | "D406", 112 | "D407", 113 | "DTZ005", 114 | "FIX002", 115 | "G004", 116 | "INP001", 117 | "ISC001", 118 | "S101", 119 | "T201", 120 | "TD003", 121 | ] 122 | select = [ 123 | "ALL", 124 | ] 125 | 126 | [tool.ruff.lint.isort] 127 | required-imports = [ 128 | "from __future__ import annotations", 129 | ] 130 | 131 | [tool.ruff.lint.mccabe] 132 | max-complexity = 10 133 | 134 | [tool.ruff.lint.pydocstyle] 135 | convention = "google" 136 | 137 | [tool.uv] 138 | default-groups = [ 139 | "docs", 140 | "lint", 141 | "test", 142 | ] 143 | 144 | [tool.uv.sources.pyprojectsort] 145 | workspace = true 146 | -------------------------------------------------------------------------------- /pyprojectsort/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package of `pyprojectsort`.""" 2 | 3 | from __future__ import annotations 4 | 5 | from .__version__ import __version__ 6 | from .main import reformat_pyproject 7 | 8 | __all__ = ("__version__", "reformat_pyproject") 9 | -------------------------------------------------------------------------------- /pyprojectsort/__main__.py: -------------------------------------------------------------------------------- 1 | """Entry point to run pyprojectsort.""" 2 | 3 | from __future__ import annotations 4 | 5 | from .main import main 6 | 7 | if __name__ == "__main__": 8 | main() 9 | -------------------------------------------------------------------------------- /pyprojectsort/__version__.py: -------------------------------------------------------------------------------- 1 | """Package version. 2 | 3 | This package uses Semantic Versioning, see: https://semver.org. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | MAJOR = 0 9 | MINOR = 4 10 | MICRO = 0 11 | 12 | __version__ = f"{MAJOR}.{MINOR}.{MICRO}" 13 | -------------------------------------------------------------------------------- /pyprojectsort/main.py: -------------------------------------------------------------------------------- 1 | """pyprojectsort implementation.""" 2 | 3 | from __future__ import annotations 4 | 5 | import argparse 6 | import pathlib 7 | import sys 8 | from difflib import unified_diff 9 | from typing import Any 10 | 11 | import natsort 12 | import tomli as tomllib 13 | import tomli_w 14 | 15 | from . import __version__ 16 | 17 | DEFAULT_CONFIG = "pyproject.toml" 18 | 19 | 20 | def _bubble_sort(array: list[dict | list]) -> list[dict | list]: 21 | """Bubble sort algorithm for sorting an array of lists or dictionaries. 22 | 23 | Examples: 24 | >>> _bubble_sort([[4, 3], [1, 2]]) 25 | [[1, 2], [4, 3]] 26 | >>> _bubble_sort([[1.0, 3, 4], ["1", 2]]) 27 | [['1', 2], [1.0, 3, 4]] 28 | >>> _bubble_sort([{"b": 1}, {"a": 2}]) 29 | [{'a': 2}, {'b': 1}] 30 | >>> _bubble_sort([{"a": 2}, {"a": 1}]) 31 | [{'a': 1}, {'a': 2}] 32 | >>> _bubble_sort([{"a": 1}, {"a": 2}]) 33 | [{'a': 1}, {'a': 2}] 34 | >>> _bubble_sort([]) 35 | [] 36 | """ 37 | for i in range(len(array)): 38 | already_sorted = True 39 | for j in range(len(array) - i - 1): 40 | first = get_comparison_array(array[j]) 41 | second = get_comparison_array(array[j + 1]) 42 | 43 | if first == second: 44 | first = get_comparison_array(array[j], values=True) 45 | second = get_comparison_array(array[j + 1], values=True) 46 | 47 | if first > second: 48 | array[j], array[j + 1] = array[j + 1], array[j] 49 | already_sorted = False 50 | 51 | if already_sorted: 52 | break 53 | return array 54 | 55 | 56 | def get_comparison_array( 57 | items: list | dict, 58 | values: bool = False, # noqa: FBT001, FBT002 59 | ) -> list[str]: 60 | """Returns an array from an iterable to be used for comparison. 61 | 62 | Dictionary keys are returned by default, and values if specified. 63 | 64 | Examples: 65 | >>> get_comparison_array([2, 4, 5]) 66 | ['2', '4', '5'] 67 | >>> get_comparison_array({"a": 1, "b": 2}) 68 | ['a', 'b'] 69 | >>> get_comparison_array({"a": 1, "b": 2}, values=True) 70 | ['1', '2'] 71 | """ 72 | if isinstance(items, dict): 73 | items = items.values() if values else items.keys() 74 | return list(map(str, items)) 75 | 76 | 77 | def _read_cli(args: list) -> argparse.Namespace: 78 | """Parse command line arguments.""" 79 | parser = argparse.ArgumentParser( 80 | prog="pyprojectsort", 81 | description="Formatter for pyproject.toml files", 82 | ) 83 | parser.add_argument("file", nargs="?", default=DEFAULT_CONFIG) 84 | parser.add_argument( 85 | "--version", 86 | action="version", 87 | version=__version__, 88 | help="show package version and exit", 89 | ) 90 | parser.add_argument( 91 | "--check", 92 | help=( 93 | "Don't write the files back, just return the status. Return code 0 means" 94 | " nothing would change. Return code 1 means the file would be reformatted" 95 | ), 96 | action="store_true", 97 | ) 98 | parser.add_argument( 99 | "--diff", 100 | help="Don't write the files back, just output a diff of changes", 101 | action="store_true", 102 | ) 103 | return parser.parse_args(args) 104 | 105 | 106 | def _read_config_file(config: pathlib.Path) -> pathlib.Path: 107 | """Check configuration file exists.""" 108 | if not config.is_file(): 109 | print(f"No pyproject.toml detected at path: '{config}'") 110 | sys.exit(1) 111 | return config 112 | 113 | 114 | def _parse_pyproject_toml(file: pathlib.Path) -> dict[str, Any]: 115 | """Parse pyproject.toml file.""" 116 | with file.open("r") as pyproject_file: 117 | return pyproject_file.read() 118 | 119 | 120 | def reformat_pyproject(pyproject: dict | list) -> dict: 121 | """Reformat pyproject toml file.""" 122 | if isinstance(pyproject, dict): 123 | return { 124 | key: reformat_pyproject(value) 125 | for key, value in natsort.natsorted(pyproject.items()) 126 | } 127 | if isinstance(pyproject, list): 128 | data_types = {bool: [], float: [], int: [], str: [], list: [], dict: []} 129 | 130 | def update_data_type(item: Any) -> None: 131 | """Populate data types map based on item type.""" 132 | data_type = type(item) 133 | container = data_types[data_type] 134 | container.append(reformat_pyproject(item)) 135 | 136 | list(map(update_data_type, pyproject)) 137 | 138 | return ( 139 | data_types[bool] 140 | + sorted(data_types[int] + data_types[float], key=float) 141 | + natsort.natsorted(data_types[str]) 142 | + _bubble_sort(data_types[list]) 143 | + _bubble_sort(data_types[dict]) 144 | ) 145 | return pyproject 146 | 147 | 148 | def _check_format_needed(original: str, reformatted: str) -> bool: 149 | """Check if there are any differences between original and reformatted.""" 150 | return original != reformatted 151 | 152 | 153 | def _save_pyproject(file: pathlib.Path, pyproject: dict) -> None: 154 | """Write changes to pyproject.toml.""" 155 | with file.open("wb") as pyproject_file: 156 | tomli_w.dump(pyproject, pyproject_file) 157 | 158 | 159 | def main() -> None: 160 | """Run application.""" 161 | args = _read_cli(sys.argv[1:]) 162 | pyproject_file = _read_config_file(pathlib.Path(args.file)) 163 | 164 | if args.diff and args.check: 165 | print("Use of 'check' with 'diff' is redundant. Please use one or the other.") 166 | sys.exit(1) 167 | 168 | text: str = _parse_pyproject_toml(pyproject_file) 169 | toml: dict = tomllib.loads(text) 170 | toml_reformatted: dict = reformat_pyproject(toml) 171 | text_reformatted: str = tomli_w.dumps(toml_reformatted) 172 | 173 | will_reformat = _check_format_needed(text, text_reformatted) 174 | 175 | if args.diff: 176 | if will_reformat: 177 | for line in unified_diff(text.split("\n"), text_reformatted.split("\n")): 178 | print(line) 179 | print(f"\n'{args.file}' would be reformatted") 180 | sys.exit(1) 181 | print(f"'{args.file}' would be left unchanged") 182 | return 183 | 184 | if args.check: 185 | if will_reformat: 186 | print(f"'{args.file}' would be reformatted") 187 | sys.exit(1) 188 | 189 | print(f"'{args.file}' would be left unchanged") 190 | return 191 | 192 | if will_reformat: 193 | _save_pyproject(pyproject_file, toml_reformatted) 194 | print(f"Reformatted '{args.file}'") 195 | sys.exit(1) 196 | 197 | print(f"'{args.file}' left unchanged") 198 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """pyprojectsort unit tests.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pathlib 6 | import sys 7 | import unittest.mock 8 | from io import StringIO 9 | 10 | import pytest 11 | 12 | from pyprojectsort import __version__ 13 | from pyprojectsort.main import ( 14 | _check_format_needed, 15 | _read_cli, 16 | _read_config_file, 17 | main, 18 | reformat_pyproject, 19 | ) 20 | 21 | 22 | class OutputCapture: 23 | """Context manager to capture console output.""" 24 | 25 | def __init__(self) -> None: 26 | """Initialise context manager.""" 27 | self.text = StringIO() 28 | 29 | def __enter__(self): 30 | """Enter context manager.""" 31 | sys.stdout = self.text 32 | return self 33 | 34 | def __exit__(self, exc_type, exc_value, exc_tb): 35 | """Exit context manager.""" 36 | sys.stdout = sys.__stdout__ 37 | self.text = self.text.getvalue().strip("\n") 38 | 39 | 40 | def test_default_filename(): 41 | """Check expected default pyproject filename.""" 42 | assert _read_cli([]).file == "pyproject.toml" 43 | 44 | 45 | def test_version(): 46 | """Program successfully displays package version and exits.""" 47 | with pytest.raises(SystemExit) as version, OutputCapture() as output: 48 | _read_cli(["--version"]) 49 | 50 | assert version.value.code == 0 51 | assert output.text == __version__ 52 | 53 | 54 | def test_invalid_config_file_path(): 55 | """SystemExit raised if config file path does not exist.""" 56 | with pytest.raises(SystemExit) as invalid_config, OutputCapture() as output: 57 | _read_config_file(pathlib.Path("test_data.toml")) 58 | 59 | assert invalid_config.value.code == 1 60 | assert output.text == "No pyproject.toml detected at path: 'test_data.toml'" 61 | 62 | 63 | @unittest.mock.patch("pathlib.Path.is_file") 64 | def test_valid_config_file_path(is_file): 65 | """Test a valid file path is provided.""" 66 | is_file.return_value = True 67 | file_path = pathlib.Path("test_data.toml") 68 | assert _read_config_file(file_path) == file_path 69 | 70 | 71 | def test_reformat_pyproject(): 72 | """Test pyproject toml is reformatted.""" 73 | pyproject = { 74 | "project": {"name": "pyprojectsort"}, 75 | "build-system": {"name": "flit"}, 76 | "tool.pylint": {"ignore": ["docs", "tests", "venv", 1, 1.1, {}]}, 77 | "tool.black": {"line_length": 88}, 78 | } 79 | 80 | # TODO(@kieran-ryan): Amend test to validate order 81 | sorted_pyproject = { 82 | "build-system": {"name": "flit"}, 83 | "project": {"name": "pyprojectsort"}, 84 | "tool.black": {"line_length": 88}, 85 | "tool.pylint": {"ignore": [1, 1.1, "docs", "tests", "venv", {}]}, 86 | } 87 | assert reformat_pyproject(pyproject) == sorted_pyproject 88 | 89 | 90 | class CLIArgs: 91 | """Test class for command line arguments.""" 92 | 93 | def __init__( 94 | self, 95 | file: str = "test_data.toml", 96 | check: bool | None = None, 97 | diff: bool | None = None, 98 | ): 99 | """Initialise test data arguments.""" 100 | self.file = file 101 | self.check = check 102 | self.diff = diff 103 | 104 | 105 | @unittest.mock.patch("pyprojectsort.main._read_config_file") 106 | @unittest.mock.patch("pyprojectsort.main._read_cli") 107 | def test_main_with_check_and_diff_options(read_cli, read_config): 108 | """SystemExit with error code if both check and diff CLI options provided.""" 109 | args = CLIArgs(check=True, diff=True) 110 | read_cli.return_value = args 111 | 112 | with pytest.raises(SystemExit) as reformatted, OutputCapture() as output: 113 | main() 114 | 115 | assert reformatted.value.code == 1 116 | assert ( 117 | output.text 118 | == "Use of 'check' with 'diff' is redundant. Please use one or the other." 119 | ) 120 | 121 | 122 | @unittest.mock.patch("pyprojectsort.main._save_pyproject") 123 | @unittest.mock.patch("pyprojectsort.main.reformat_pyproject") 124 | @unittest.mock.patch("pyprojectsort.main._parse_pyproject_toml") 125 | @unittest.mock.patch("pyprojectsort.main._read_config_file") 126 | @unittest.mock.patch("pyprojectsort.main._read_cli") 127 | def test_main_with_file_reformatted( 128 | read_cli, 129 | read_config, 130 | parse_pyproject, 131 | reformat_pyproject, 132 | save_project, 133 | ): 134 | """Test file reformatted.""" 135 | args = CLIArgs() 136 | read_cli.return_value = args 137 | read_config.return_value = pathlib.Path() 138 | parse_pyproject.return_value = "change = 1" 139 | reformat_pyproject.return_value = {"change": 1} 140 | 141 | with pytest.raises(SystemExit) as reformatted, OutputCapture() as output: 142 | main() 143 | 144 | assert reformatted.value.code == 1 145 | assert f"Reformatted '{args.file}'" in output.text 146 | 147 | 148 | @unittest.mock.patch("pyprojectsort.main._save_pyproject") 149 | @unittest.mock.patch("pyprojectsort.main.reformat_pyproject") 150 | @unittest.mock.patch("pyprojectsort.main._parse_pyproject_toml") 151 | @unittest.mock.patch("pyprojectsort.main._read_config_file") 152 | @unittest.mock.patch("pyprojectsort.main._read_cli") 153 | def test_main_with_file_unchanged( 154 | read_cli, 155 | read_config, 156 | parse_pyproject, 157 | reformat_pyproject, 158 | save_pyproject, 159 | ): 160 | """Test file left unchanged.""" 161 | args = CLIArgs() 162 | read_cli.return_value = args 163 | read_config.return_value = pathlib.Path() 164 | parse_pyproject.return_value = "" 165 | reformat_pyproject.return_value = {} 166 | 167 | with OutputCapture() as output: 168 | main() 169 | 170 | assert f"'{args.file}' left unchanged" in output.text 171 | 172 | 173 | @unittest.mock.patch("pyprojectsort.main.reformat_pyproject") 174 | @unittest.mock.patch("pyprojectsort.main._parse_pyproject_toml") 175 | @unittest.mock.patch("pyprojectsort.main._read_config_file") 176 | @unittest.mock.patch("pyprojectsort.main._read_cli") 177 | def test_check_option_reformat_needed( 178 | read_cli, 179 | read_config, 180 | parse_pyproject, 181 | reformat_pyproject, 182 | ): 183 | """Test --check option when reformat occurs.""" 184 | args = CLIArgs(check=True) 185 | read_cli.return_value = args 186 | read_config.return_value = pathlib.Path() 187 | parse_pyproject.return_value = "change = 1" 188 | reformat_pyproject.return_value = {"change": 1} 189 | 190 | with pytest.raises(SystemExit) as would_reformat, OutputCapture() as output: 191 | main() 192 | 193 | assert f"'{args.file}' would be reformatted" in output.text 194 | assert would_reformat.value.code == 1 195 | 196 | 197 | @pytest.mark.parametrize( 198 | ("original"), 199 | [ 200 | ('unsorted = [\n "tests",\n "docs",\n]\n'), 201 | ('not-indented = [\n "docs",\n"tests",\n]\n'), 202 | ('no-trailing-comma = [\n "docs",\n "tests"\n]\n'), 203 | ('not-line-per-list-value = ["docs","tests"]\n'), 204 | ('extra_spaces = "value"\n'), 205 | ('no-newline-at-end-of-file = "value"'), 206 | ("single-quotes = 'value'\n"), 207 | ], 208 | ) 209 | @unittest.mock.patch("pyprojectsort.main._parse_pyproject_toml") 210 | @unittest.mock.patch("pyprojectsort.main._read_config_file") 211 | @unittest.mock.patch("pyprojectsort.main._read_cli") 212 | def test_check_would_reformat( 213 | read_cli, 214 | read_config, 215 | parse_pyproject, 216 | original, 217 | ): 218 | """Test --check option when reformat occurs.""" 219 | args = CLIArgs(check=True) 220 | read_cli.return_value = args 221 | read_config.return_value = pathlib.Path() 222 | parse_pyproject.return_value = original 223 | 224 | with pytest.raises(SystemExit) as would_reformat, OutputCapture() as output: 225 | main() 226 | print(output.text) 227 | assert f"'{args.file}' would be reformatted" in output.text 228 | assert would_reformat.value.code == 1 229 | 230 | 231 | @unittest.mock.patch("pyprojectsort.main.reformat_pyproject") 232 | @unittest.mock.patch("pyprojectsort.main._parse_pyproject_toml") 233 | @unittest.mock.patch("pyprojectsort.main._read_config_file") 234 | @unittest.mock.patch("pyprojectsort.main._read_cli") 235 | def test_check_option_reformat_not_needed( 236 | read_cli, 237 | read_config, 238 | parse_pyproject, 239 | reformat_pyproject, 240 | ): 241 | """Test --check option when reformat is not needed.""" 242 | args = CLIArgs(check=True) 243 | read_cli.return_value = args 244 | read_config.return_value = pathlib.Path() 245 | parse_pyproject.return_value = "unchanged = 1\n" 246 | reformat_pyproject.return_value = {"unchanged": 1} 247 | 248 | with OutputCapture() as output: 249 | main() 250 | 251 | assert f"'{args.file}' would be left unchanged" in output.text 252 | 253 | 254 | @unittest.mock.patch("pyprojectsort.main.reformat_pyproject") 255 | @unittest.mock.patch("pyprojectsort.main._parse_pyproject_toml") 256 | @unittest.mock.patch("pyprojectsort.main._read_config_file") 257 | @unittest.mock.patch("pyprojectsort.main._read_cli") 258 | def test_diff_option_reformat_needed( 259 | read_cli, 260 | read_config, 261 | parse_pyproject, 262 | reformat_pyproject, 263 | ): 264 | """Test --diff option when reformat occurs.""" 265 | args = CLIArgs(diff=True) 266 | read_cli.return_value = args 267 | read_config.return_value = pathlib.Path() 268 | parse_pyproject.return_value = "change = 1" 269 | reformat_pyproject.return_value = {"change": 1} 270 | 271 | with pytest.raises(SystemExit) as would_reformat, OutputCapture() as output: 272 | main() 273 | 274 | assert f"'{args.file}' would be reformatted" in output.text 275 | assert would_reformat.value.code == 1 276 | 277 | 278 | @pytest.mark.parametrize( 279 | ("original"), 280 | [ 281 | ('unsorted = [\n "tests",\n "docs",\n]\n'), 282 | ('not-indented = [\n "docs",\n"tests",\n]\n'), 283 | ('no-trailing-comma = [\n "docs",\n "tests"\n]\n'), 284 | ('not-line-per-list-value = ["docs","tests"]\n'), 285 | ('extra_spaces = "value"\n'), 286 | ('no-newline-at-end-of-file = "value"'), 287 | ("single-quotes = 'value'\n"), 288 | ], 289 | ) 290 | @unittest.mock.patch("pyprojectsort.main._parse_pyproject_toml") 291 | @unittest.mock.patch("pyprojectsort.main._read_config_file") 292 | @unittest.mock.patch("pyprojectsort.main._read_cli") 293 | def test_diff_would_reformat( 294 | read_cli, 295 | read_config, 296 | parse_pyproject, 297 | original, 298 | ): 299 | """Test --diff option when reformat occurs.""" 300 | args = CLIArgs(diff=True) 301 | read_cli.return_value = args 302 | read_config.return_value = pathlib.Path() 303 | parse_pyproject.return_value = original 304 | 305 | with pytest.raises(SystemExit) as would_reformat, OutputCapture() as output: 306 | main() 307 | print(output.text) 308 | assert f"'{args.file}' would be reformatted" in output.text 309 | assert would_reformat.value.code == 1 310 | 311 | 312 | @unittest.mock.patch("pyprojectsort.main.reformat_pyproject") 313 | @unittest.mock.patch("pyprojectsort.main._parse_pyproject_toml") 314 | @unittest.mock.patch("pyprojectsort.main._read_config_file") 315 | @unittest.mock.patch("pyprojectsort.main._read_cli") 316 | def test_diff_option_reformat_not_needed( 317 | read_cli, 318 | read_config, 319 | parse_pyproject, 320 | reformat_pyproject, 321 | ): 322 | """Test --diff option when reformat is not needed.""" 323 | args = CLIArgs(diff=True) 324 | read_cli.return_value = args 325 | read_config.return_value = pathlib.Path() 326 | parse_pyproject.return_value = "unchanged = 1\n" 327 | reformat_pyproject.return_value = {"unchanged": 1} 328 | 329 | with OutputCapture() as output: 330 | main() 331 | 332 | assert f"'{args.file}' would be left unchanged" in output.text 333 | 334 | 335 | @pytest.mark.parametrize( 336 | ("original", "reformatted", "expected_result"), 337 | [ 338 | ( 339 | '[tool.pylint]\nignore = [\n\t"tests",\n\t"docs",\n]', 340 | '[tool.pylint]\nignore = [\n\t"tests",\n\t"docs",\n]', 341 | False, 342 | ), 343 | ( 344 | '[tool.pylint]\nignore = [\n\t"tests",\n\t"docs",\n]', 345 | '[tool.pylint]\nignore = [\n\t"docs",\n\t"tests",\n]', 346 | True, 347 | ), 348 | ], 349 | ) 350 | def test_check_format_needed(original, reformatted, expected_result): 351 | """Test _check_format_needed function with different test cases.""" 352 | assert _check_format_needed(original, reformatted) == expected_result 353 | -------------------------------------------------------------------------------- /tests/test_pyprojectsort.py: -------------------------------------------------------------------------------- 1 | """Package top-level unit tests.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pyprojectsort 6 | 7 | 8 | def test_package(): 9 | """Package top-level contains version information.""" 10 | assert "__version__" in pyprojectsort.__all__ 11 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | """Package version unit tests.""" 2 | 3 | from __future__ import annotations 4 | 5 | import packaging.version 6 | import pytest 7 | 8 | from pyprojectsort.__version__ import __version__ 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ("version_component", "version_type"), 13 | [ 14 | (packaging.version.parse(__version__), packaging.version.Version), 15 | (packaging.version.parse(__version__).major, int), 16 | (packaging.version.parse(__version__).minor, int), 17 | (packaging.version.parse(__version__).micro, int), 18 | ], 19 | ) 20 | def test_version_is_valid(version_component, version_type): 21 | """Package version is valid.""" 22 | assert isinstance(version_component, version_type) 23 | --------------------------------------------------------------------------------