pypfmt 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+
51
+ # Translations
52
+ *.mo
53
+ *.pot
54
+
55
+ # Django stuff:
56
+ *.log
57
+ local_settings.py
58
+ db.sqlite3
59
+ db.sqlite3-journal
60
+
61
+ # Flask stuff:
62
+ instance/
63
+ .webassets-cache
64
+
65
+ # Scrapy stuff:
66
+ .scrapy
67
+
68
+ # Sphinx documentation
69
+ docs/_build/
70
+
71
+ # PyBuilder
72
+ .pybuilder/
73
+ target/
74
+
75
+ # Jupyter Notebook
76
+ .ipynb_checkpoints
77
+
78
+ # IPython
79
+ profile_default/
80
+ ipython_config.py
81
+
82
+ # pyenv
83
+ .python-version
84
+
85
+ # pipenv
86
+ Pipfile.lock
87
+
88
+ # PEP 582
89
+ __pypackages__/
90
+
91
+ # Celery stuff
92
+ celerybeat-schedule
93
+ celerybeat.pid
94
+
95
+ # SageMath parsed files
96
+ *.sage.py
97
+
98
+ # Environments
99
+ .env
100
+ .venv
101
+ env/
102
+ venv/
103
+ ENV/
104
+ env.bak/
105
+ venv.bak/
106
+
107
+ # Spyder project settings
108
+ .spyderproject
109
+ .spyproject
110
+
111
+ # Rope project settings
112
+ .ropeproject
113
+
114
+ # mkdocs documentation
115
+ /site
116
+
117
+ # mypy
118
+ .mypy_cache/
119
+ .dmypy.json
120
+ dmypy.json
121
+
122
+ # Pyre type checker
123
+ .pyre/
124
+
125
+ # ruff
126
+ .ruff_cache/
127
+
128
+ # ty
129
+ .ty_cache/
130
+
131
+ # IDE
132
+ .idea/
133
+ .vscode/
134
+ *.swp
135
+ *.swo
136
+ *~
137
+
138
+ # OS
139
+ .DS_Store
140
+ Thumbs.db
141
+
142
+ # Project specific
143
+ *.log
144
+ .env.*
145
+ !.env.example
pypfmt-0.1.0/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+
2
+ MIT License
3
+
4
+ Copyright (c) 2026 Jamie McGregor Nelson
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
pypfmt-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: pypfmt
3
+ Version: 0.1.0
4
+ Summary: A Python package to sort and format pyproject.toml
5
+ Project-URL: Changelog, https://github.com/bitflight-devops/pyproject-fmt/blob/main/CHANGELOG.md
6
+ Project-URL: Documentation, https://bitflight-devops.github.io/pyproject-fmt
7
+ Project-URL: Homepage, https://github.com/bitflight-devops/pyproject-fmt
8
+ Project-URL: Issues, https://github.com/bitflight-devops/pyproject-fmt/issues
9
+ Project-URL: Repository, https://github.com/bitflight-devops/pyproject-fmt
10
+ Author-email: Jamie McGregor Nelson <jamie@bitflight.io>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: taplo>=0.9.0
26
+ Requires-Dist: toml-sort<0.25,>=0.24.0
27
+ Requires-Dist: typer>=0.12.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # pypfmt
31
+
32
+ [![CI](https://github.com/bitflight-devops/pyproject-fmt/actions/workflows/ci.yml/badge.svg)](https://github.com/bitflight-devops/pyproject-fmt/actions/workflows/ci.yml)
33
+ [![PyPI version](https://badge.fury.io/py/pypfmt.svg)](https://badge.fury.io/py/pypfmt)
34
+ [![codecov](https://codecov.io/gh/bitflight-devops/pyproject-fmt/branch/main/graph/badge.svg)](https://codecov.io/gh/bitflight-devops/pyproject-fmt)
35
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
36
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
37
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
38
+ [![ty](https://img.shields.io/badge/type--checked-ty-blue?labelColor=orange)](https://github.com/astral-sh/ty)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/bitflight-devops/pyproject-fmt/blob/main/LICENSE)
40
+
41
+ A Python package to sort and format pyproject.toml
42
+
43
+ ## Features
44
+
45
+ - Fast and modern Python toolchain using Astral's tools (uv, ruff, ty)
46
+ - Type-safe with full type annotations
47
+ - Command-line interface built with Typer
48
+ - Comprehensive documentation with MkDocs — [View Docs](https://bitflight-devops.github.io/pyproject-fmt/)
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ pip install pypfmt
54
+ ```
55
+
56
+ Or using uv (recommended):
57
+
58
+ ```bash
59
+ uv add pypfmt
60
+ ```
61
+
62
+ ## Quick Start
63
+
64
+ ```python
65
+ import pypfmt
66
+
67
+ print(pypfmt.__version__)
68
+ ```
69
+
70
+ ### CLI Usage
71
+
72
+ ```bash
73
+ # Show version
74
+ pypfmt --version
75
+
76
+ # Say hello
77
+ pypfmt hello World
78
+ ```
79
+
80
+ ## Development
81
+
82
+ ### Prerequisites
83
+
84
+ - Python 3.11+
85
+ - [uv](https://docs.astral.sh/uv/) for package management
86
+
87
+ ### Setup
88
+
89
+ ```bash
90
+ git clone https://github.com/bitflight-devops/pyproject-fmt.git
91
+ cd pyproject-fmt
92
+ uv sync --all-groups
93
+ ```
94
+
95
+ ### Running Tests
96
+
97
+ ```bash
98
+ uv run poe test
99
+
100
+ # With coverage
101
+ uv run poe test-cov
102
+
103
+ # Across all Python versions
104
+ uv run poe test-matrix
105
+ ```
106
+
107
+ ### Code Quality
108
+
109
+ ```bash
110
+ # Run all checks (lint, format, type-check)
111
+ uv run poe verify
112
+
113
+ # Auto-fix lint and format issues
114
+ uv run poe fix
115
+ ```
116
+
117
+ ### Prek
118
+
119
+ ```bash
120
+ prek install
121
+ prek run --all-files
122
+ ```
123
+
124
+ ### Documentation
125
+
126
+ ```bash
127
+ uv run poe docs-serve
128
+ ```
129
+
130
+ ## License
131
+
132
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
pypfmt-0.1.0/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # pypfmt
2
+
3
+ [![CI](https://github.com/bitflight-devops/pyproject-fmt/actions/workflows/ci.yml/badge.svg)](https://github.com/bitflight-devops/pyproject-fmt/actions/workflows/ci.yml)
4
+ [![PyPI version](https://badge.fury.io/py/pypfmt.svg)](https://badge.fury.io/py/pypfmt)
5
+ [![codecov](https://codecov.io/gh/bitflight-devops/pyproject-fmt/branch/main/graph/badge.svg)](https://codecov.io/gh/bitflight-devops/pyproject-fmt)
6
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
7
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
8
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
9
+ [![ty](https://img.shields.io/badge/type--checked-ty-blue?labelColor=orange)](https://github.com/astral-sh/ty)
10
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/bitflight-devops/pyproject-fmt/blob/main/LICENSE)
11
+
12
+ A Python package to sort and format pyproject.toml
13
+
14
+ ## Features
15
+
16
+ - Fast and modern Python toolchain using Astral's tools (uv, ruff, ty)
17
+ - Type-safe with full type annotations
18
+ - Command-line interface built with Typer
19
+ - Comprehensive documentation with MkDocs — [View Docs](https://bitflight-devops.github.io/pyproject-fmt/)
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install pypfmt
25
+ ```
26
+
27
+ Or using uv (recommended):
28
+
29
+ ```bash
30
+ uv add pypfmt
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ```python
36
+ import pypfmt
37
+
38
+ print(pypfmt.__version__)
39
+ ```
40
+
41
+ ### CLI Usage
42
+
43
+ ```bash
44
+ # Show version
45
+ pypfmt --version
46
+
47
+ # Say hello
48
+ pypfmt hello World
49
+ ```
50
+
51
+ ## Development
52
+
53
+ ### Prerequisites
54
+
55
+ - Python 3.11+
56
+ - [uv](https://docs.astral.sh/uv/) for package management
57
+
58
+ ### Setup
59
+
60
+ ```bash
61
+ git clone https://github.com/bitflight-devops/pyproject-fmt.git
62
+ cd pyproject-fmt
63
+ uv sync --all-groups
64
+ ```
65
+
66
+ ### Running Tests
67
+
68
+ ```bash
69
+ uv run poe test
70
+
71
+ # With coverage
72
+ uv run poe test-cov
73
+
74
+ # Across all Python versions
75
+ uv run poe test-matrix
76
+ ```
77
+
78
+ ### Code Quality
79
+
80
+ ```bash
81
+ # Run all checks (lint, format, type-check)
82
+ uv run poe verify
83
+
84
+ # Auto-fix lint and format issues
85
+ uv run poe fix
86
+ ```
87
+
88
+ ### Prek
89
+
90
+ ```bash
91
+ prek install
92
+ prek run --all-files
93
+ ```
94
+
95
+ ### Documentation
96
+
97
+ ```bash
98
+ uv run poe docs-serve
99
+ ```
100
+
101
+ ## License
102
+
103
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,199 @@
1
+ [project]
2
+ name = "pypfmt"
3
+ version = "0.1.0"
4
+ description = "A Python package to sort and format pyproject.toml"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Jamie McGregor Nelson", email = "jamie@bitflight.io" },
8
+ ]
9
+ license = "MIT"
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Typing :: Typed",
22
+ ]
23
+ keywords = []
24
+ requires-python = ">=3.11"
25
+ dependencies = [
26
+ "taplo>=0.9.0",
27
+ "toml-sort>=0.24.0,<0.25",
28
+ "typer>=0.12.0",
29
+ ]
30
+
31
+ [project.scripts]
32
+ pypfmt = "pypfmt.cli:app"
33
+
34
+ [project.urls]
35
+ Changelog = "https://github.com/bitflight-devops/pyproject-fmt/blob/main/CHANGELOG.md"
36
+ Documentation = "https://bitflight-devops.github.io/pyproject-fmt"
37
+ Homepage = "https://github.com/bitflight-devops/pyproject-fmt"
38
+ Issues = "https://github.com/bitflight-devops/pyproject-fmt/issues"
39
+ Repository = "https://github.com/bitflight-devops/pyproject-fmt"
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "hatch>=1.16.3",
48
+ "poethepoet>=0.32.0",
49
+ "prek>=0.1.0",
50
+ "pysentry-rs>=0.1.0",
51
+ "pytest-cov>=7.0.0",
52
+ "pytest>=9.0.0",
53
+ "ruff>=0.14.14",
54
+ "ty>=0.0.14",
55
+ ]
56
+ docs = [
57
+ "mkdocs-material>=9.7.0",
58
+ "mkdocs>=1.6.0",
59
+ "mkdocstrings-python>=2.0.1",
60
+ ]
61
+
62
+ [tool.coverage.report]
63
+ exclude_lines = [
64
+ "@abstractmethod",
65
+ "def __repr__",
66
+ "if TYPE_CHECKING:",
67
+ "if __name__ == .__main__.:",
68
+ "pragma: no cover",
69
+ "raise AssertionError",
70
+ "raise NotImplementedError",
71
+ ]
72
+ show_missing = true
73
+
74
+ [tool.coverage.run]
75
+ branch = true
76
+ parallel = true
77
+ source = ["src/pypfmt"]
78
+
79
+ [tool.git-cliff]
80
+ config = "cliff.toml"
81
+
82
+ [tool.hatch.build]
83
+
84
+ [tool.hatch.build.targets.sdist]
85
+ include = [
86
+ "/src",
87
+ ]
88
+
89
+ [tool.hatch.build.targets.wheel]
90
+ packages = ["src/pypfmt"]
91
+
92
+ # Matrix testing across Python versions
93
+ [tool.hatch.envs.test]
94
+
95
+ [[tool.hatch.envs.test.matrix]]
96
+ python = ["3.10", "3.11", "3.12", "3.13", "3.14"]
97
+
98
+ [tool.pytest.ini_options]
99
+ addopts = [
100
+ "--strict-config",
101
+ "--strict-markers",
102
+ "-q",
103
+ "-ra",
104
+ ]
105
+ markers = [
106
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
107
+ ]
108
+ pythonpath = ["src"]
109
+ testpaths = ["tests"]
110
+
111
+ [tool.ruff]
112
+ line-length = 88
113
+ src = ["src", "tests"]
114
+ target-version = "py311"
115
+
116
+ [tool.ruff.lint]
117
+ select = [
118
+ "ARG", # flake8-unused-arguments
119
+ "B", # flake8-bugbear
120
+ "C4", # flake8-comprehensions
121
+ "E", # pycodestyle errors
122
+ "ERA", # eradicate
123
+ "F", # Pyflakes
124
+ "I", # isort
125
+ "PTH", # flake8-use-pathlib
126
+ "RUF", # Ruff-specific rules
127
+ "SIM", # flake8-simplify
128
+ "TCH", # flake8-type-checking
129
+ "UP", # pyupgrade
130
+ "W", # pycodestyle warnings
131
+ ]
132
+
133
+ [tool.ruff.lint.isort]
134
+ known-first-party = ["pypfmt"]
135
+
136
+ [tool.ruff.lint.per-file-ignores]
137
+ "tests/**/*.py" = ["ARG001"]
138
+
139
+ [tool.ty.src]
140
+ include = ["**/*.py"]
141
+
142
+ [tool.poe.tasks.lint]
143
+ cmd = "ruff check ."
144
+ help = "Run ruff linter"
145
+
146
+ [tool.poe.tasks.format-check]
147
+ cmd = "ruff format --check ."
148
+ help = "Check code formatting"
149
+
150
+ [tool.poe.tasks.format]
151
+ cmd = "ruff format ."
152
+ help = "Format code"
153
+
154
+ [tool.poe.tasks.type-check]
155
+ cmd = "ty check"
156
+ help = "Run ty type checker"
157
+
158
+ [tool.poe.tasks.verify]
159
+ sequence = ["lint", "format-check", "type-check"]
160
+ help = "Run all checks (lint, format-check, type-check)"
161
+
162
+ [tool.poe.tasks.fix]
163
+ sequence = [
164
+ { cmd = "ruff check --fix ." },
165
+ { cmd = "ruff format ." },
166
+ ]
167
+ help = "Auto-fix lint and format issues"
168
+
169
+ [tool.poe.tasks.install]
170
+ cmd = "uv sync --all-groups"
171
+ help = "Install all dependencies"
172
+
173
+ [tool.poe.tasks.test]
174
+ cmd = "pytest tests/ -v"
175
+ help = "Run tests"
176
+
177
+ [tool.poe.tasks.test-cov]
178
+ cmd = "pytest --cov --cov-report=xml --cov-report=term-missing"
179
+ help = "Run tests with coverage"
180
+
181
+ [tool.poe.tasks.test-matrix]
182
+ cmd = "hatch test"
183
+ help = "Run tests across all Python versions"
184
+
185
+ [tool.poe.tasks.test-matrix-cov]
186
+ cmd = "hatch test --cover"
187
+ help = "Run tests with coverage across all Python versions"
188
+
189
+ [tool.poe.tasks.pysentry]
190
+ cmd = "pysentry-rs"
191
+ help = "Run dependency vulnerability scanning"
192
+
193
+ [tool.poe.tasks.docs]
194
+ cmd = "mkdocs build"
195
+ help = "Build documentation"
196
+
197
+ [tool.poe.tasks.docs-serve]
198
+ cmd = "mkdocs serve"
199
+ help = "Serve documentation locally"
@@ -0,0 +1,3 @@
1
+ """A Python package to sort and format pyproject.toml (pypfmt)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running pypfmt as ``python -m pypfmt``."""
2
+
3
+ from pypfmt.cli import app
4
+
5
+ app()
@@ -0,0 +1,205 @@
1
+ """Command-line interface for pypfmt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import sys
7
+ import tomllib
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from pypfmt import __version__
14
+ from pypfmt.config import (
15
+ MergedConfig,
16
+ check_config_conflict,
17
+ load_config,
18
+ merge_config,
19
+ )
20
+ from pypfmt.pipeline import format_pyproject
21
+
22
+ _RED = "\033[31m"
23
+ _GREEN = "\033[32m"
24
+ _CYAN = "\033[36m"
25
+ _RESET = "\033[0m"
26
+
27
+ app = typer.Typer(
28
+ name="pypfmt",
29
+ help="Sort and format pyproject.toml files.",
30
+ add_completion=False,
31
+ )
32
+
33
+
34
+ def _version_callback(value: bool) -> None:
35
+ """Print version and exit."""
36
+ if value:
37
+ typer.echo(f"pypfmt {__version__}")
38
+ raise typer.Exit()
39
+
40
+
41
+ def _print_diff(original: str, formatted: str, filename: str) -> None:
42
+ """Print unified diff, colored if stdout is a terminal."""
43
+ diff_lines = difflib.unified_diff(
44
+ original.splitlines(keepends=True),
45
+ formatted.splitlines(keepends=True),
46
+ fromfile=f"a/{filename}",
47
+ tofile=f"b/{filename}",
48
+ )
49
+ use_color = sys.stdout.isatty()
50
+ for line in diff_lines:
51
+ if use_color:
52
+ if line.startswith(("---", "+++", "@@")):
53
+ sys.stdout.write(f"{_CYAN}{line}{_RESET}")
54
+ elif line.startswith("-"):
55
+ sys.stdout.write(f"{_RED}{line}{_RESET}")
56
+ elif line.startswith("+"):
57
+ sys.stdout.write(f"{_GREEN}{line}{_RESET}")
58
+ else:
59
+ sys.stdout.write(line)
60
+ else:
61
+ sys.stdout.write(line)
62
+
63
+
64
+ def _load_and_warn(text: str) -> MergedConfig | None:
65
+ """Load config from text, emit conflict warning, return merged config.
66
+
67
+ Returns a ``MergedConfig`` 5-tuple when ``[tool.pypfmt]`` is
68
+ present, or ``None`` so the pipeline uses its hardcoded defaults.
69
+
70
+ If the TOML is invalid, returns ``None`` -- the pipeline's own
71
+ validation will catch and report the parse error.
72
+ """
73
+ try:
74
+ warning = check_config_conflict(text)
75
+ except tomllib.TOMLDecodeError:
76
+ return None
77
+
78
+ if warning is not None:
79
+ typer.echo(warning, err=True)
80
+
81
+ user_config = load_config(text)
82
+ if user_config is None:
83
+ return None
84
+
85
+ return merge_config(user_config)
86
+
87
+
88
+ def _format_with_config(text: str, merged: MergedConfig | None) -> str:
89
+ """Run ``format_pyproject`` with optional merged config."""
90
+ if merged is None:
91
+ return format_pyproject(text)
92
+ sort_cfg, overrides, comment_cfg, format_cfg, taplo_opts = merged
93
+ return format_pyproject(
94
+ text,
95
+ sort_config=sort_cfg,
96
+ sort_overrides=overrides,
97
+ comment_config=comment_cfg,
98
+ format_config=format_cfg,
99
+ taplo_options=taplo_opts,
100
+ )
101
+
102
+
103
+ def _process_file(filepath: str, *, check: bool, diff: bool) -> int:
104
+ """Process a single file through the formatting pipeline.
105
+
106
+ Returns:
107
+ 0 on success (or no changes needed), 1 on error or check failure.
108
+ """
109
+ try:
110
+ text = Path(filepath).read_text(encoding="utf-8")
111
+ except FileNotFoundError:
112
+ typer.echo(f"error: {filepath}: file not found", err=True)
113
+ return 1
114
+ except PermissionError:
115
+ typer.echo(f"error: {filepath}: permission denied", err=True)
116
+ return 1
117
+
118
+ merged = _load_and_warn(text)
119
+
120
+ try:
121
+ result = _format_with_config(text, merged)
122
+ except tomllib.TOMLDecodeError as exc:
123
+ typer.echo(f"error: {filepath}: {exc}", err=True)
124
+ return 1
125
+
126
+ if text == result:
127
+ return 0
128
+
129
+ # File needs changes
130
+ if check and diff:
131
+ _print_diff(text, result, filepath)
132
+ return 1
133
+ if check:
134
+ typer.echo(f"error: {filepath}: not properly formatted", err=True)
135
+ return 1
136
+ if diff:
137
+ _print_diff(text, result, filepath)
138
+ return 0
139
+
140
+ # Fix mode: write back
141
+ Path(filepath).write_text(result, encoding="utf-8")
142
+ typer.echo(f"{filepath}: reformatted", err=True)
143
+ return 0
144
+
145
+
146
+ @app.command()
147
+ def main(
148
+ files: Annotated[
149
+ list[str] | None,
150
+ typer.Argument(help="pyproject.toml files to format"),
151
+ ] = None,
152
+ check: Annotated[
153
+ bool,
154
+ typer.Option(
155
+ "--check", help="Check if files are formatted, exit non-zero if not"
156
+ ),
157
+ ] = False,
158
+ diff: Annotated[
159
+ bool,
160
+ typer.Option("--diff", help="Show unified diff of changes"),
161
+ ] = False,
162
+ version: Annotated[ # noqa: ARG001
163
+ bool | None,
164
+ typer.Option(
165
+ "--version",
166
+ "-v",
167
+ callback=_version_callback,
168
+ is_eager=True,
169
+ ),
170
+ ] = None,
171
+ ) -> None:
172
+ """Sort and format pyproject.toml files."""
173
+ if not files:
174
+ # Stdin mode
175
+ text = sys.stdin.read()
176
+ merged = _load_and_warn(text)
177
+ try:
178
+ result = _format_with_config(text, merged)
179
+ except tomllib.TOMLDecodeError as exc:
180
+ typer.echo(f"error: stdin: {exc}", err=True)
181
+ raise typer.Exit(code=1) from None
182
+
183
+ if check and diff:
184
+ if text != result:
185
+ _print_diff(text, result, "stdin")
186
+ raise typer.Exit(code=0 if text == result else 1)
187
+ if check:
188
+ raise typer.Exit(code=0 if text == result else 1)
189
+ if diff:
190
+ if text != result:
191
+ _print_diff(text, result, "stdin")
192
+ raise typer.Exit()
193
+ # Fix mode: write formatted output to stdout
194
+ typer.echo(result, nl=False)
195
+ else:
196
+ # File mode
197
+ exit_code = 0
198
+ for filepath in files:
199
+ code = _process_file(filepath, check=check, diff=diff)
200
+ exit_code = max(exit_code, code)
201
+ raise typer.Exit(code=exit_code)
202
+
203
+
204
+ if __name__ == "__main__":
205
+ app()
@@ -0,0 +1,336 @@
1
+ """Sort and format configuration for pypfmt.
2
+
3
+ Hardcoded defaults plus optional user overrides from [tool.pypfmt].
4
+ Override pattern modeled on ruff: extend-* adds to defaults, plain key replaces.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import os
11
+ import tomllib
12
+
13
+ from toml_sort.tomlsort import (
14
+ CommentConfiguration,
15
+ FormattingConfiguration,
16
+ SortConfiguration,
17
+ SortOverrideConfiguration,
18
+ )
19
+
20
+ # Maps [tool.pypfmt] TOML keys to the config field they control.
21
+ # Serves as documentation and reference for error messages.
22
+ SORT_KEY_MAP: dict[str, str] = {
23
+ "sort-first": "first (replace)",
24
+ "extend-sort-first": "first (extend)",
25
+ "sort-tables": "tables",
26
+ "sort-table-keys": "table_keys",
27
+ "sort-inline-tables": "inline_tables",
28
+ "sort-inline-arrays": "inline_arrays",
29
+ "ignore-case": "ignore_case",
30
+ }
31
+
32
+ COMMENT_KEY_MAP: dict[str, str] = {
33
+ "comments-header": "header",
34
+ "comments-footer": "footer",
35
+ "comments-inline": "inline",
36
+ "comments-block": "block",
37
+ }
38
+
39
+ FORMAT_KEY_MAP: dict[str, str] = {
40
+ "spaces-before-inline-comment": "spaces_before_inline_comment",
41
+ "spaces-indent-inline-array": "spaces_indent_inline_array",
42
+ "trailing-comma-inline-array": "trailing_comma_inline_array",
43
+ }
44
+
45
+
46
+ def get_sort_config() -> SortConfiguration:
47
+ """Return the global sort configuration with locked defaults.
48
+
49
+ Global inline_arrays=False preserves array element order by default.
50
+ Only arrays with explicit inline_arrays=True overrides are sorted
51
+ alphabetically (classifiers, extend-select, ignore, dependency-groups).
52
+
53
+ Global table_keys=False preserves key order within tables by default.
54
+ Tables needing first-list ordering (project, build-system) must have
55
+ table_keys=True on their override for the first list to take effect.
56
+
57
+ The root first list controls only ROOT-level table ordering.
58
+ Sub-table ordering (e.g., tool.ruff vs tool.pytest) is controlled
59
+ by the first list on the PARENT table's override (e.g., "tool" override).
60
+ This mirrors how toml-sort CLI's parse_sort_first decomposes dotted keys.
61
+ """
62
+ return SortConfiguration(
63
+ tables=True,
64
+ table_keys=False,
65
+ inline_tables=False,
66
+ inline_arrays=False,
67
+ ignore_case=False,
68
+ first=[
69
+ "build-system",
70
+ "project",
71
+ "dependency-groups",
72
+ ],
73
+ )
74
+
75
+
76
+ def get_sort_overrides() -> dict[str, SortOverrideConfiguration]:
77
+ """Return per-table sort override configurations.
78
+
79
+ Global defaults: inline_arrays=False (preserve order), table_keys=False
80
+ (preserve key order). Overrides selectively enable sorting where needed.
81
+
82
+ The first list on parent-table overrides controls sub-table ordering.
83
+ This mirrors how toml-sort CLI's parse_sort_first decomposes dotted
84
+ keys: "tool.ruff" becomes first=["ruff"] on the "tool" override.
85
+
86
+ - table_keys=True on build-system/project enables first-list key ordering
87
+ - first list on "tool" override controls tool sub-table ordering
88
+ - first=["*"] on sub-tool overrides ensures sub-sub-tables sort correctly
89
+ - inline_arrays=True on specific array paths enables alphabetical sorting
90
+ - tool.tomlsort overrides explicitly preserve its own config section
91
+ """
92
+ return {
93
+ # Tables needing first-list key ordering (table_keys=True required
94
+ # for the first list to work with global table_keys=False)
95
+ "build-system": SortOverrideConfiguration(
96
+ table_keys=True,
97
+ first=["requires", "build-backend"],
98
+ ),
99
+ "project": SortOverrideConfiguration(
100
+ table_keys=True,
101
+ first=[
102
+ "name",
103
+ "dynamic",
104
+ "description",
105
+ "readme",
106
+ "authors",
107
+ "maintainers",
108
+ "license",
109
+ "classifiers",
110
+ "keywords",
111
+ "requires-python",
112
+ "dependencies",
113
+ "*",
114
+ ],
115
+ ),
116
+ # Tool sub-table ordering (mirrors parse_sort_first decomposition)
117
+ "tool": SortOverrideConfiguration(
118
+ first=[
119
+ "git-cliff",
120
+ "pypis_delivery_service",
121
+ "ty",
122
+ "uv",
123
+ "ruff",
124
+ "mypy",
125
+ "pyright",
126
+ "basedpyright",
127
+ "pylint",
128
+ "isort",
129
+ "black",
130
+ "pytest",
131
+ "coverage",
132
+ "semantic_release",
133
+ "hatch",
134
+ "*",
135
+ "tomlsort",
136
+ ],
137
+ ),
138
+ # Sub-tool table ordering matching golden file specification.
139
+ # toml-sort sorts sub-tables alphabetically by default (tables=True).
140
+ # These first lists override to match the golden file order.
141
+ "tool.ruff.lint": SortOverrideConfiguration(
142
+ first=["per-file-ignores", "pycodestyle", "pydocstyle", "mccabe"],
143
+ ),
144
+ "tool.coverage": SortOverrideConfiguration(
145
+ first=["run", "report"],
146
+ ),
147
+ "tool.hatch": SortOverrideConfiguration(
148
+ first=["version", "build"],
149
+ ),
150
+ # Arrays that SHOULD be sorted alphabetically
151
+ "project.classifiers": SortOverrideConfiguration(inline_arrays=True),
152
+ "tool.ruff.lint.extend-select": SortOverrideConfiguration(inline_arrays=True),
153
+ "tool.ruff.lint.ignore": SortOverrideConfiguration(inline_arrays=True),
154
+ # Dependency groups: sort array elements alphabetically
155
+ "dependency-groups.*": SortOverrideConfiguration(inline_arrays=True),
156
+ # tomlsort section: preserve as-is (no sorting)
157
+ "tool.tomlsort": SortOverrideConfiguration(
158
+ table_keys=False,
159
+ inline_arrays=False,
160
+ first=["*"],
161
+ ),
162
+ "tool.tomlsort.*": SortOverrideConfiguration(
163
+ table_keys=False,
164
+ inline_arrays=False,
165
+ ),
166
+ }
167
+
168
+
169
+ def get_comment_config() -> CommentConfiguration:
170
+ """Return comment preservation configuration."""
171
+ return CommentConfiguration(
172
+ header=True,
173
+ footer=True,
174
+ inline=True,
175
+ block=True,
176
+ )
177
+
178
+
179
+ def get_format_config() -> FormattingConfiguration:
180
+ """Return formatting configuration aligned with taplo options.
181
+
182
+ spaces_indent_inline_array=4 matches taplo indent_string (4 spaces).
183
+ trailing_comma_inline_array=True matches taplo array_trailing_comma=true.
184
+ """
185
+ return FormattingConfiguration(
186
+ spaces_before_inline_comment=2,
187
+ spaces_indent_inline_array=4,
188
+ trailing_comma_inline_array=True,
189
+ )
190
+
191
+
192
+ TAPLO_OPTIONS: tuple[str, ...] = (
193
+ "reorder_keys=false",
194
+ "indent_string= ",
195
+ "array_auto_collapse=false",
196
+ "array_auto_expand=true",
197
+ "array_trailing_comma=true",
198
+ "align_comments=true",
199
+ "column_width=80",
200
+ "allowed_blank_lines=2",
201
+ )
202
+ """taplo CLI -o key=value pairs for formatting."""
203
+
204
+ _CONFLICT_WARNING = (
205
+ "warning: [tool.tomlsort] and [tool.pypfmt] both present. "
206
+ "toml-sort should not be used against pyproject.toml files when also "
207
+ "using pypfmt, since results and ordering will be outside of "
208
+ "pypfmt's control."
209
+ )
210
+
211
+
212
+ def load_config(text: str) -> dict | None:
213
+ """Extract ``[tool.pypfmt]`` from TOML text.
214
+
215
+ Pure extraction -- no merging logic. Returns the raw dict when the
216
+ section exists, ``None`` when it doesn't.
217
+ """
218
+ data = tomllib.loads(text)
219
+ return data.get("tool", {}).get("pypfmt", None)
220
+
221
+
222
+ def check_config_conflict(text: str) -> str | None:
223
+ """Return a warning string if both tomlsort and pypfmt config exist.
224
+
225
+ Returns ``None`` when there is no conflict or when the warning is
226
+ suppressed via the ``PPF_HIDE_CONFLICT_WARNING`` environment variable.
227
+ """
228
+ data = tomllib.loads(text)
229
+ tool = data.get("tool", {})
230
+ if (
231
+ "tomlsort" in tool
232
+ and "pypfmt" in tool
233
+ and not os.environ.get("PPF_HIDE_CONFLICT_WARNING")
234
+ ):
235
+ return _CONFLICT_WARNING
236
+ return None
237
+
238
+
239
+ def _merge_sort_config(default: SortConfiguration, user: dict) -> SortConfiguration:
240
+ """Apply user overrides to the default SortConfiguration."""
241
+ replacements: dict[str, object] = {}
242
+ if "sort-first" in user:
243
+ replacements["first"] = list(user["sort-first"])
244
+ elif "extend-sort-first" in user:
245
+ replacements["first"] = list(default.first) + list(user["extend-sort-first"])
246
+ if "sort-tables" in user:
247
+ replacements["tables"] = user["sort-tables"]
248
+ if "sort-table-keys" in user:
249
+ replacements["table_keys"] = user["sort-table-keys"]
250
+ if "sort-inline-tables" in user:
251
+ replacements["inline_tables"] = user["sort-inline-tables"]
252
+ if "sort-inline-arrays" in user:
253
+ replacements["inline_arrays"] = user["sort-inline-arrays"]
254
+ if "ignore-case" in user:
255
+ replacements["ignore_case"] = user["ignore-case"]
256
+ return dataclasses.replace(default, **replacements) if replacements else default
257
+
258
+
259
+ def _merge_sort_overrides(
260
+ default: dict[str, SortOverrideConfiguration], user: dict
261
+ ) -> dict[str, SortOverrideConfiguration]:
262
+ """Apply user overrides to the per-table sort overrides."""
263
+ if "overrides" in user:
264
+ # Replace: start fresh from user dict only
265
+ return {
266
+ path: SortOverrideConfiguration(**cfg)
267
+ for path, cfg in user["overrides"].items()
268
+ }
269
+ if "extend-overrides" in user:
270
+ # Extend: copy defaults, then update with user entries
271
+ merged = dict(default)
272
+ for path, cfg in user["extend-overrides"].items():
273
+ merged[path] = SortOverrideConfiguration(**cfg)
274
+ return merged
275
+ return default
276
+
277
+
278
+ def _merge_comment_config(
279
+ default: CommentConfiguration, user: dict
280
+ ) -> CommentConfiguration:
281
+ """Apply user overrides to CommentConfiguration."""
282
+ replacements: dict[str, object] = {}
283
+ for toml_key, field_name in COMMENT_KEY_MAP.items():
284
+ if toml_key in user:
285
+ replacements[field_name] = user[toml_key]
286
+ return dataclasses.replace(default, **replacements) if replacements else default
287
+
288
+
289
+ def _merge_format_config(
290
+ default: FormattingConfiguration, user: dict
291
+ ) -> FormattingConfiguration:
292
+ """Apply user overrides to FormattingConfiguration."""
293
+ replacements: dict[str, object] = {}
294
+ for toml_key, field_name in FORMAT_KEY_MAP.items():
295
+ if toml_key in user:
296
+ replacements[field_name] = user[toml_key]
297
+ return dataclasses.replace(default, **replacements) if replacements else default
298
+
299
+
300
+ def _merge_taplo_options(default: tuple[str, ...], user: dict) -> tuple[str, ...]:
301
+ """Apply user overrides to taplo options."""
302
+ if "taplo-options" in user:
303
+ return tuple(user["taplo-options"])
304
+ if "extend-taplo-options" in user:
305
+ return default + tuple(user["extend-taplo-options"])
306
+ return default
307
+
308
+
309
+ MergedConfig = tuple[
310
+ SortConfiguration,
311
+ dict[str, SortOverrideConfiguration],
312
+ CommentConfiguration,
313
+ FormattingConfiguration,
314
+ tuple[str, ...],
315
+ ]
316
+
317
+
318
+ def merge_config(user: dict) -> MergedConfig:
319
+ """Merge user overrides with hardcoded defaults.
320
+
321
+ Takes the raw dict from ``load_config()`` and returns a 5-tuple of
322
+ merged config objects. Defaults are never mutated -- new instances
323
+ are created via ``dataclasses.replace()``.
324
+ """
325
+ default_sort = get_sort_config()
326
+ default_overrides = get_sort_overrides()
327
+ default_comment = get_comment_config()
328
+ default_format = get_format_config()
329
+
330
+ return (
331
+ _merge_sort_config(default_sort, user),
332
+ _merge_sort_overrides(default_overrides, user),
333
+ _merge_comment_config(default_comment, user),
334
+ _merge_format_config(default_format, user),
335
+ _merge_taplo_options(TAPLO_OPTIONS, user),
336
+ )
@@ -0,0 +1,54 @@
1
+ """TOML formatting via taplo subprocess.
2
+
3
+ Wraps the taplo CLI binary to apply whitespace and style formatting.
4
+ This is the second stage of the pipeline: format after sorting.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+
12
+ from pypfmt.config import TAPLO_OPTIONS
13
+
14
+
15
+ def format_toml(
16
+ text: str,
17
+ taplo_options: tuple[str, ...] | None = None,
18
+ ) -> str:
19
+ """Format a TOML string using taplo subprocess.
20
+
21
+ Args:
22
+ text: Valid TOML content as a string.
23
+ taplo_options: taplo -o key=value pairs, or None for defaults.
24
+
25
+ Returns:
26
+ The formatted TOML string with consistent whitespace,
27
+ indentation, and style.
28
+
29
+ Raises:
30
+ RuntimeError: If taplo binary is not found or formatting fails.
31
+ """
32
+ options = taplo_options if taplo_options is not None else TAPLO_OPTIONS
33
+
34
+ taplo_bin = shutil.which("taplo")
35
+ if taplo_bin is None:
36
+ msg = "taplo binary not found. Install via: pip install taplo"
37
+ raise RuntimeError(msg)
38
+
39
+ cmd: list[str] = [taplo_bin, "format", "--no-auto-config"]
40
+ for option in options:
41
+ cmd.extend(["-o", option])
42
+ cmd.append("-")
43
+
44
+ result = subprocess.run(
45
+ cmd,
46
+ input=text,
47
+ capture_output=True,
48
+ text=True,
49
+ check=False,
50
+ )
51
+ if result.returncode != 0:
52
+ msg = f"taplo format failed: {result.stderr}"
53
+ raise RuntimeError(msg)
54
+ return result.stdout
@@ -0,0 +1,67 @@
1
+ """Pipeline orchestrator: validate -> sort -> format.
2
+
3
+ Chains TOML validation, sorting, and formatting into a single
4
+ str -> str transformation. This is the public API for pypfmt.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import tomllib
10
+ from typing import TYPE_CHECKING
11
+
12
+ from pypfmt.formatter import format_toml
13
+ from pypfmt.sorter import sort_toml
14
+
15
+ if TYPE_CHECKING:
16
+ from toml_sort.tomlsort import (
17
+ CommentConfiguration,
18
+ FormattingConfiguration,
19
+ SortConfiguration,
20
+ SortOverrideConfiguration,
21
+ )
22
+
23
+
24
+ def format_pyproject(
25
+ text: str,
26
+ sort_config: SortConfiguration | None = None,
27
+ sort_overrides: dict[str, SortOverrideConfiguration] | None = None,
28
+ comment_config: CommentConfiguration | None = None,
29
+ format_config: FormattingConfiguration | None = None,
30
+ taplo_options: tuple[str, ...] | None = None,
31
+ ) -> str:
32
+ """Format a pyproject.toml string through the full pipeline.
33
+
34
+ Validates TOML syntax, sorts tables and keys via toml-sort,
35
+ then formats whitespace and style via taplo.
36
+
37
+ When config parameters are ``None``, hardcoded defaults are used.
38
+
39
+ Args:
40
+ text: Raw pyproject.toml content as a string.
41
+ sort_config: Global sort configuration, or None for defaults.
42
+ sort_overrides: Per-table sort overrides, or None for defaults.
43
+ comment_config: Comment handling configuration, or None for defaults.
44
+ format_config: Formatting configuration, or None for defaults.
45
+ taplo_options: taplo -o key=value pairs, or None for defaults.
46
+
47
+ Returns:
48
+ The sorted and formatted TOML string.
49
+
50
+ Raises:
51
+ tomllib.TOMLDecodeError: If the input is not valid TOML.
52
+ RuntimeError: If taplo binary is not found or formatting fails.
53
+ """
54
+ # Validate input -- let TOMLDecodeError propagate naturally
55
+ tomllib.loads(text)
56
+
57
+ # Stage 1: Sort tables and keys
58
+ sorted_text = sort_toml(
59
+ text,
60
+ sort_config=sort_config,
61
+ sort_overrides=sort_overrides,
62
+ comment_config=comment_config,
63
+ format_config=format_config,
64
+ )
65
+
66
+ # Stage 2: Format whitespace and style
67
+ return format_toml(sorted_text, taplo_options=taplo_options)
File without changes
@@ -0,0 +1,58 @@
1
+ """TOML sorting via toml-sort library API.
2
+
3
+ Wraps TomlSort with configuration to produce a sorted TOML string.
4
+ This is the first stage of the pipeline: sort tables and keys.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from toml_sort import TomlSort
12
+
13
+ if TYPE_CHECKING:
14
+ from toml_sort.tomlsort import (
15
+ CommentConfiguration,
16
+ FormattingConfiguration,
17
+ SortConfiguration,
18
+ SortOverrideConfiguration,
19
+ )
20
+
21
+ from pypfmt.config import (
22
+ get_comment_config,
23
+ get_format_config,
24
+ get_sort_config,
25
+ get_sort_overrides,
26
+ )
27
+
28
+
29
+ def sort_toml(
30
+ text: str,
31
+ sort_config: SortConfiguration | None = None,
32
+ sort_overrides: dict[str, SortOverrideConfiguration] | None = None,
33
+ comment_config: CommentConfiguration | None = None,
34
+ format_config: FormattingConfiguration | None = None,
35
+ ) -> str:
36
+ """Sort a TOML string using toml-sort.
37
+
38
+ When config parameters are ``None``, hardcoded defaults are used.
39
+
40
+ Args:
41
+ text: Valid TOML content as a string.
42
+ sort_config: Global sort configuration, or None for defaults.
43
+ sort_overrides: Per-table sort overrides, or None for defaults.
44
+ comment_config: Comment handling configuration, or None for defaults.
45
+ format_config: Formatting configuration, or None for defaults.
46
+
47
+ Returns:
48
+ The sorted TOML string with tables and keys reordered
49
+ according to the configuration.
50
+ """
51
+ sorter = TomlSort(
52
+ input_toml=text,
53
+ sort_config=sort_config or get_sort_config(),
54
+ comment_config=comment_config or get_comment_config(),
55
+ format_config=format_config or get_format_config(),
56
+ sort_config_overrides=sort_overrides or get_sort_overrides(),
57
+ )
58
+ return sorter.sorted()