├── .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 | [](https://pypi.org/project/pyprojectsort/)
8 | 
9 | [](https://pypi.org/pypi/pyprojectsort)
10 | 
11 | 
12 | [](https://results.pre-commit.ci/latest/github/kieran-ryan/pyprojectsort/main)
13 | [](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 |
--------------------------------------------------------------------------------