diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..665ae51 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +tidelift: "pypi/pyproject-fmt" diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..f9506b7 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.0 + | :white_check_mark: | +| < 1.0 | :x: | + +## Reporting a Vulnerability + +To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift +will coordinate the fix and disclosure. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1230149 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..9d1e098 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,5 @@ +changelog: + exclude: + authors: + - dependabot + - pre-commit-ci diff --git a/.github/workflows/common.yaml b/.github/workflows/common.yaml new file mode 100644 index 0000000..8be2ff2 --- /dev/null +++ b/.github/workflows/common.yaml @@ -0,0 +1,49 @@ +name: Test common +on: + workflow_dispatch: + push: + branches: ["main"] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: clippy + - name: Lint rust code with clippy + run: cargo clippy -p common --all-targets -- -D warnings + + rust-fmt: + runs-on: ubuntu-latest + defaults: + run: + working-directory: common + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: rustfmt + - name: Rust formatting check + run: cargo fmt -p common --check + + rust-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Build library + run: cargo build -p common + + rust-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - run: cargo test -p common diff --git a/.github/workflows/pyproject_fmt_build.yaml b/.github/workflows/pyproject_fmt_build.yaml new file mode 100644 index 0000000..2a8f659 --- /dev/null +++ b/.github/workflows/pyproject_fmt_build.yaml @@ -0,0 +1,239 @@ +name: Build pyproject-fmt +on: + workflow_dispatch: + inputs: + release: + description: "Cut a release (select semver bump)?" + required: true + default: "no" + type: choice + options: + - "no" + - patch + - minor + - major + push: + branches: ["main"] + pull_request: + schedule: + - cron: "0 8 * * *" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event.inputs.release == 'no' || github.event.inputs.release == null }} + +permissions: + contents: read + +jobs: + bump: + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + version: ${{ steps.get-version.outputs.version }} + changelog: ${{ steps.get-version.outputs.changelog }} + steps: + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Install cargo-edit from crates.io + uses: baptiste0928/cargo-install@v3 + with: + crate: cargo-edit + - uses: actions/checkout@v4 + - name: Bump version + run: + cargo set-version -p pyproject-fmt --bump '${{ github.event.inputs.release == 'no' || + github.event.inputs.release == null && 'patch' || github.event.inputs.release }}' + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: "tasks/changelog.py" + - name: Generate changelog + id: get-version + run: + uv run tasks/changelog.py pyproject-fmt "${{ github.event.number }}" "${{ github.event.pull_request.base.sha + }}" + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + - uses: actions/setup-python@v5 + - uses: pre-commit/action@v3.0.1 + continue-on-error: true + - name: Show changes to the repository + run: git diff HEAD -u + - name: Store the patched distribution + uses: actions/upload-artifact@v4 + with: + name: source + path: . + compression-level: 9 + retention-days: 1 + if-no-files-found: "error" + + linux: + needs: [bump] + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + platform: + - runner: ubuntu-latest + target: x86_64 + interpreter: "3.8 pypy3.8 pypy3.9 pypy3.10" + - runner: ubuntu-latest + target: x86 + - runner: ubuntu-latest + target: x86_64-unknown-linux-musl + manylinux: musllinux_1_1 + - runner: ubuntu-latest + target: i686-unknown-linux-musl + manylinux: musllinux_1_1 + - runner: ubuntu-latest + target: aarch64 + - runner: ubuntu-latest + target: armv7 + - runner: ubuntu-latest + target: s390x + - runner: ubuntu-latest + target: ppc64le + steps: + - name: Download the code + uses: actions/download-artifact@v4 + with: + name: source + - name: Build wheels + uses: PyO3/maturin-action@e21013af641daf9513198d98a6853760803a5c6c + + with: + target: ${{ matrix.platform.target }} + args: + -m pyproject-fmt/Cargo.toml --release --out dist --interpreter ${{ matrix.platform.interpreter || '3.8' }} + --target-dir target + sccache: "true" + manylinux: ${{ matrix.platform.manylinux || 'auto' }} + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + windows: + needs: [bump] + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - name: Download the code + uses: actions/download-artifact@v4 + with: + name: source + - name: Build wheels + uses: PyO3/maturin-action@e21013af641daf9513198d98a6853760803a5c6c + + with: + target: ${{ matrix.platform.target }} + args: + -m pyproject-fmt/Cargo.toml --release --out dist --interpreter ${{ matrix.platform.interpreter || '3.8' }} + --target-dir target + sccache: "true" + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + + macos: + needs: [bump] + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-latest + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - name: Download the code + uses: actions/download-artifact@v4 + with: + name: source + - name: Build wheels + uses: PyO3/maturin-action@e21013af641daf9513198d98a6853760803a5c6c + + with: + target: ${{ matrix.platform.target }} + args: + -m pyproject-fmt/Cargo.toml --release --out dist --interpreter "3.8 pypy3.8 pypy3.9 pypy3.10" --target-dir + target + sccache: "true" + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + + sdist: + needs: [bump] + runs-on: ubuntu-latest + steps: + - name: Download the code + uses: actions/download-artifact@v4 + with: + name: source + - name: Build sdist + uses: PyO3/maturin-action@e21013af641daf9513198d98a6853760803a5c6c + + with: + command: sdist + args: -m pyproject-fmt/Cargo.toml --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/project/pyproject-fmt/${{ needs.bump.outputs.version }} + permissions: + id-token: write + contents: write + if: github.event.inputs.release != 'no' && github.event.inputs.release != null && github.ref == 'main' + needs: [linux, windows, macos, sdist, bump] + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + - name: Show changes to the repository + run: git diff HEAD -u + - name: Commit changes + run: | + git config --global user.name 'Bernat Gabor' + git config --global user.email 'gaborbernat@users.noreply.github.com' + git commit -am "Release pyproject-fmt ${{needs.bump.outputs.version}}" + - name: Tag release + run: git tag pyproject-fmt/${{needs.bump.outputs.version}} + - name: Publish to PyPI + uses: PyO3/maturin-action@e21013af641daf9513198d98a6853760803a5c6c + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* + - name: Push release commit+tag and create GitHub release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git push + git push --tags + gh release create "pyproject-fmt/${{needs.bump.outputs.version}}" \ + --title="pyproject-fmt/${{needs.bump.outputs.version}}" --verify-tag + --notes "$(cat << 'EOM' + ${{ needs.bump.outputs.changelog }} + EOM + )" diff --git a/.github/workflows/pyproject_fmt_test.yaml b/.github/workflows/pyproject_fmt_test.yaml new file mode 100644 index 0000000..1a923ee --- /dev/null +++ b/.github/workflows/pyproject_fmt_test.yaml @@ -0,0 +1,128 @@ +name: Test pyproject-fmt +on: + workflow_dispatch: + push: + branches: ["main"] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: clippy + - name: Lint rust code with clippy + run: cargo clippy -p pyproject-fmt --all-targets -- -D warnings + + rust-fmt: + runs-on: ubuntu-latest + defaults: + run: + working-directory: pyproject-fmt + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: rustfmt + - name: Rust formatting check + run: cargo fmt -p pyproject-fmt --check + + rust-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Build library + run: cargo build -p pyproject-fmt + + rust-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - run: cargo test -p pyproject-fmt + + py-test: + name: test ${{ matrix.py }} ${{ matrix.os }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + py: + - "3.13" + - "3.12" + - "3.11" + - "3.10" + - "3.9" + os: + - ubuntu-latest + - windows-latest + - macos-latest + defaults: + run: + working-directory: pyproject-fmt + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + - name: Install tox + run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv + - name: Install Python + if: matrix.py != '3.13' + run: uv python install --python-preference only-managed ${{ matrix.env }} + - uses: moonrepo/setup-rust@v1 + with: + cache-base: main + bins: cargo-tarpaulin + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup test suite + run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.py }} + - name: Run test suite + run: tox run --skip-pkg-install -e ${{ matrix.py }} + env: + PYTEST_ADDOPTS: "-vv --durations=20" + + py-check: + name: tox env ${{ matrix.env }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + env: + - type + - dev + - pkg_meta + defaults: + run: + working-directory: pyproject-fmt + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install tox + run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv + - name: Setup test suite + run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.env }} + - name: Run test suite + run: tox run --skip-pkg-install -e ${{ matrix.env }} + env: + PYTEST_ADDOPTS: "-vv --durations=20" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c5c88c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/common/target +/target +/pyproject-fmt/.tox +/pyproject-fmt/dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2e7c0eb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.29.4 + hooks: + - id: check-github-workflows + args: ["--verbose"] + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + additional_dependencies: ["tomli>=2.0.1"] + - repo: https://github.com/tox-dev/pyproject-fmt + rev: "2.3.1" + hooks: + - id: pyproject-fmt + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.6.9" + hooks: + - id: ruff-format + args: ["--config", "pyproject.toml"] + - id: ruff + args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix", "--config", "pyproject.toml"] + - repo: https://github.com/rbubley/mirrors-prettier + rev: "v3.3.3" + hooks: + - id: prettier + args: ["--print-width=120", "--prose-wrap=always"] + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..7530651 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1 @@ +max_width = 120 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..267eaab --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,60 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making +participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, +disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take +appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, +issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any +contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the +project or its community. Examples of representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed representative at an online or offline +event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at +gaborbernat@python.org. The project team will review and investigate all complaints, and will respond in a way that it +deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the +reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent +repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at +[https://www.contributor-covenant.org/version/1/4/code-of-conduct.html][version] + +[homepage]: https://www.contributor-covenant.org/ +[version]: https://www.contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2180711 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contributing to toml-fmt + +Thank you for your interest in contributing to toml-fmt! There are many ways to contribute, and we appreciate all of +them. As a reminder, all contributors are expected to follow our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Development Setup + +To work on the project: + +1. Install Rust (preferably through [rustup](https://rustup.rs)). +2. Clone the repository. +3. Build the project and run the unit tests: + + ```bash + # build a projects rust code + cargo build -p common + cargo build -p pyproject-fmt + + # run a projects rust test code + cargo test -p common + cargo test -p pyproject-fmt + + # for pyo3 objects use tox to run Python tests + tox run -e 3.13 + tox run -e type + ``` + +## License + +By contributing to toml-fmt, you agree that your contributions will be licensed under the [MIT License](LICENSE). + +Thank you for your contributions! If you have any questions or need further assistance, feel free to reach out via +GitHub issues. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..34554e0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,960 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "any_ascii" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70033777eb8b5124a81a1889416543dddef2de240019b674c81285a2635a7e1e" + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bstr" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "common" +version = "1.0.0" +dependencies = [ + "indoc", + "lexical-sort", + "pep440_rs", + "pep508_rs", + "rstest", + "taplo", +] + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "globset" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f1ce686646e7f1e19bf7d5533fe443a45dbfb990e00629110797578b42fb19" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax 0.8.5", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown 0.15.0", +] + +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "lexical-sort" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09e4591611e231daf4d4c685a66cb0410cc1e502027a20ae55f2bb9e997207a" +dependencies = [ + "any_ascii", +] + +[[package]] +name = "libc" +version = "0.2.159" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "logos" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf8b031682c67a8e3d5446840f9573eb7fe26efe7ec8d195c9ac4c0647c502f1" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d849148dbaf9661a6151d1ca82b13bb4c4c128146a88d05253b38d4e2f496c" +dependencies = [ + "beef", + "fnv", + "proc-macro2", + "quote", + "regex-syntax 0.6.29", + "syn 1.0.109", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "pep440_rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "466eada3179c2e069ca897b99006cbb33f816290eaeec62464eea907e22ae385" +dependencies = [ + "once_cell", + "unicode-width", + "unscanny", +] + +[[package]] +name = "pep508_rs" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f8877489a99ccc80012333123e434f84e645fe1ede3b30e9d3b815887a12979" +dependencies = [ + "derivative", + "once_cell", + "pep440_rs", + "regex", + "thiserror", + "unicode-width", + "url", + "urlencoding", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d922163ba1f79c04bc49073ba7b32fd5a8d3b76a87c955921234b8e77333c51" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc38c5feeb496c8321091edf3d63e9a6829eab4b863b4a6a65f26f3e9cc6b179" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94845622d88ae274d2729fcefc850e63d7a3ddff5e3ce11bd88486db9f1d357d" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e655aad15e09b94ffdb3ce3d217acf652e26bbc37697ef012f5e5e348c716e5e" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1e3f09eecd94618f60a455a23def79f79eba4dc561a97324bf9ac8c6df30ce" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "pyproject-fmt" +version = "2.3.1" +dependencies = [ + "common", + "indoc", + "pyo3", + "regex", + "rstest", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rowan" +version = "0.15.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a542b0253fa46e632d27a1dc5cf7b930de4df8659dc6e720b647fc72147ae3d" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "rustc-hash", + "text-size", +] + +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.79", + "unicode-ident", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taplo" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010941ac4171eaf12f1e26dfc11dadaf78619ea2330940fef01fe6bb0442d14d" +dependencies = [ + "ahash", + "arc-swap", + "either", + "globset", + "itertools", + "logos", + "once_cell", + "rowan", + "serde", + "serde_json", + "thiserror", + "time", + "tracing", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "thiserror" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unindent" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + +[[package]] +name = "unscanny" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" + +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1ef9677 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "common", + "pyproject-fmt", +] +resolver = "2" + +[workspace.lints.clippy] +all = "warn" +pedantic = "warn" +nursery = "warn" + +[workspace.lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..3649823 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,18 @@ +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index fe638c7..462a44e 100644 --- a/README.md +++ b/README.md @@ -1 +1,9 @@ -# toml-fmt \ No newline at end of file +# Python TOML formatters + +This project includes the: + +- pyproject-fmt, +- ruff-toml-fmt, +- tox-toml-fmt, + +projects formatting your TOML files in the Python world. diff --git a/common/Cargo.toml b/common/Cargo.toml new file mode 100644 index 0000000..1ac34de --- /dev/null +++ b/common/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "common" +version = "1.0.0" +description = "Common code for formatting TOML files" +repository = "https://github.com/tox-dev/toml-fmt" +readme = "README.md" +license = "MIT" +edition = "2021" + +[dependencies] +taplo = { version = "0.13.2" } # formatter +pep508_rs = { version = "0.6.1" } +pep440_rs = { version = "0.6.5" } # align up with pep508_rs for now https://github.com/konstin/pep508_rs/issues/19 +lexical-sort = { version = "0.3.1" } + +[dev-dependencies] +rstest = { version = "0.23.0" } # parametrized tests +indoc = { version = "2.0.5" } # dedented test cases for literal strings diff --git a/common/README.md b/common/README.md new file mode 100644 index 0000000..08bcebf --- /dev/null +++ b/common/README.md @@ -0,0 +1,5 @@ +# toml-fmt common + +Contains code common to all formatters. + +[![Test common](https://github.com/tox-dev/toml-fmt/actions/workflows/common.yaml/badge.svg)](https://github.com/tox-dev/toml-fmt/actions/workflows/common.yaml) diff --git a/common/src/array.rs b/common/src/array.rs new file mode 100644 index 0000000..8b6f3ad --- /dev/null +++ b/common/src/array.rs @@ -0,0 +1,143 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use lexical_sort::{natural_lexical_cmp, StringSort}; +use taplo::syntax::SyntaxKind::{ARRAY, COMMA, NEWLINE, STRING, VALUE, WHITESPACE}; +use taplo::syntax::{SyntaxElement, SyntaxKind, SyntaxNode}; + +use crate::create::{make_comma, make_newline}; +use crate::string::{load_text, update_content}; + +pub fn transform(node: &SyntaxNode, transform: &F) +where + F: Fn(&str) -> String, +{ + for array in node.children_with_tokens() { + if array.kind() == ARRAY { + for array_entry in array.as_node().unwrap().children_with_tokens() { + if array_entry.kind() == VALUE { + update_content(array_entry.as_node().unwrap(), transform); + } + } + } + } +} + +#[allow(clippy::range_plus_one, clippy::too_many_lines)] +pub fn sort(node: &SyntaxNode, transform: F) +where + F: Fn(&str) -> String, +{ + for array in node.children_with_tokens() { + if array.kind() == ARRAY { + let array_node = array.as_node().unwrap(); + let has_trailing_comma = array_node + .children_with_tokens() + .map(|x| x.kind()) + .filter(|x| *x == COMMA || *x == VALUE) + .last() + == Some(COMMA); + let multiline = array_node.children_with_tokens().any(|e| e.kind() == NEWLINE); + let mut value_set = Vec::>::new(); + let entry_set = RefCell::new(Vec::::new()); + let mut key_to_pos = HashMap::::new(); + + let mut add_to_value_set = |entry: String| { + let mut entry_set_borrow = entry_set.borrow_mut(); + if !entry_set_borrow.is_empty() { + key_to_pos.insert(entry, value_set.len()); + value_set.push(entry_set_borrow.clone()); + entry_set_borrow.clear(); + } + }; + let mut entries = Vec::::new(); + let mut has_value = false; + let mut previous_is_bracket_open = false; + let mut entry_value = String::new(); + let mut count = 0; + + for entry in array_node.children_with_tokens() { + count += 1; + if previous_is_bracket_open { + // make sure ends with trailing comma + if entry.kind() == NEWLINE || entry.kind() == WHITESPACE { + continue; + } + previous_is_bracket_open = false; + } + match &entry.kind() { + SyntaxKind::BRACKET_START => { + entries.push(entry); + if multiline { + entries.push(make_newline()); + } + previous_is_bracket_open = true; + } + SyntaxKind::BRACKET_END => { + if has_value { + add_to_value_set(entry_value.clone()); + } else { + entries.extend(entry_set.borrow_mut().clone()); + } + entries.push(entry); + } + VALUE => { + if has_value { + if multiline { + entry_set.borrow_mut().push(make_newline()); + } + add_to_value_set(entry_value.clone()); + } + has_value = true; + let value_node = entry.as_node().unwrap(); + let mut found_string = false; + for child in value_node.children_with_tokens() { + let kind = child.kind(); + if kind == STRING { + entry_value = transform(load_text(child.as_token().unwrap().text(), STRING).as_str()); + found_string = true; + break; + } + } + if !found_string { + // abort if not correct types + return; + } + entry_set.borrow_mut().push(entry); + entry_set.borrow_mut().push(make_comma()); + } + NEWLINE => { + entry_set.borrow_mut().push(entry); + if has_value { + add_to_value_set(entry_value.clone()); + has_value = false; + } + } + COMMA => {} + _ => { + entry_set.borrow_mut().push(entry); + } + } + } + + let mut order: Vec = key_to_pos.clone().into_keys().collect(); + order.string_sort_unstable(natural_lexical_cmp); + let end = entries.split_off(if multiline { 2 } else { 1 }); + for key in order { + entries.extend(value_set[key_to_pos[&key]].clone()); + } + entries.extend(end); + array_node.splice_children(0..count, entries); + if !has_trailing_comma { + if let Some((i, _)) = array_node + .children_with_tokens() + .enumerate() + .filter(|(_, x)| x.kind() == COMMA) + .last() + { + array_node.splice_children(i..i + 1, vec![]); + } + } + } + } +} diff --git a/common/src/create.rs b/common/src/create.rs new file mode 100644 index 0000000..7edb24e --- /dev/null +++ b/common/src/create.rs @@ -0,0 +1,146 @@ +use taplo::parser::parse; +use taplo::syntax::SyntaxElement; +use taplo::syntax::SyntaxKind::{ARRAY, COMMA, ENTRY, KEY, NEWLINE, STRING, VALUE}; + +pub fn make_string_node(text: &str) -> SyntaxElement { + let expr = &format!("a = \"{}\"", text.replace('"', "\\\"")); + for root in parse(expr) + .into_syntax() + .clone_for_update() + .first_child() + .unwrap() + .children_with_tokens() + { + if root.kind() == VALUE { + for entries in root.as_node().unwrap().children_with_tokens() { + if entries.kind() == STRING { + return entries; + } + } + } + } + panic!("Could not create string element for {text:?}") +} + +pub fn make_empty_newline() -> SyntaxElement { + for root in parse("\n\n").into_syntax().clone_for_update().children_with_tokens() { + if root.kind() == NEWLINE { + return root; + } + } + panic!("Could not create empty newline"); +} + +pub fn make_newline() -> SyntaxElement { + for root in parse("\n").into_syntax().clone_for_update().children_with_tokens() { + if root.kind() == NEWLINE { + return root; + } + } + panic!("Could not create newline"); +} + +pub fn make_comma() -> SyntaxElement { + for root in parse("a=[1,2]").into_syntax().clone_for_update().children_with_tokens() { + if root.kind() == ENTRY { + for value in root.as_node().unwrap().children_with_tokens() { + if value.kind() == VALUE { + for array in value.as_node().unwrap().children_with_tokens() { + if array.kind() == ARRAY { + for e in array.as_node().unwrap().children_with_tokens() { + if e.kind() == COMMA { + return e; + } + } + } + } + } + } + } + } + panic!("Could not create comma"); +} + +pub fn make_key(text: &str) -> SyntaxElement { + for root in parse(format!("{text}=1").as_str()) + .into_syntax() + .clone_for_update() + .children_with_tokens() + { + if root.kind() == ENTRY { + for value in root.as_node().unwrap().children_with_tokens() { + if value.kind() == KEY { + return value; + } + } + } + } + panic!("Could not create key {text}"); +} + +pub fn make_array(key: &str) -> SyntaxElement { + let txt = format!("{key} = []"); + for root in parse(txt.as_str()) + .into_syntax() + .clone_for_update() + .children_with_tokens() + { + if root.kind() == ENTRY { + return root; + } + } + panic!("Could not create array"); +} + +pub fn make_array_entry(key: &str) -> SyntaxElement { + let txt = format!("a = [\"{key}\"]"); + for root in parse(txt.as_str()) + .into_syntax() + .clone_for_update() + .children_with_tokens() + { + if root.kind() == ENTRY { + for value in root.as_node().unwrap().children_with_tokens() { + if value.kind() == VALUE { + for array in value.as_node().unwrap().children_with_tokens() { + if array.kind() == ARRAY { + for e in array.as_node().unwrap().children_with_tokens() { + if e.kind() == VALUE { + return e; + } + } + } + } + } + } + } + } + panic!("Could not create array"); +} + +pub fn make_entry_of_string(key: &String, value: &String) -> SyntaxElement { + let txt = format!("{key} = \"{value}\"\n"); + for root in parse(txt.as_str()) + .into_syntax() + .clone_for_update() + .children_with_tokens() + { + if root.kind() == ENTRY { + return root; + } + } + panic!("Could not create entry of string"); +} + +pub fn make_table_entry(key: &str) -> Vec { + let txt = format!("[{key}]\n"); + let mut res = Vec::::new(); + for root in parse(txt.as_str()) + .into_syntax() + .clone_for_update() + .children_with_tokens() + { + res.push(root); + } + res +} diff --git a/common/src/lib.rs b/common/src/lib.rs new file mode 100644 index 0000000..abb7c00 --- /dev/null +++ b/common/src/lib.rs @@ -0,0 +1,10 @@ +pub mod array; +pub mod create; +pub mod pep508; +pub mod string; +pub mod table; + +pub use taplo; + +#[cfg(test)] +mod tests; diff --git a/common/src/pep508.rs b/common/src/pep508.rs new file mode 100644 index 0000000..633cc56 --- /dev/null +++ b/common/src/pep508.rs @@ -0,0 +1,87 @@ +use std::fmt::Write; +use std::str::FromStr; + +use pep508_rs::{MarkerTree, Requirement, VersionOrUrl}; + +pub fn format_requirement(value: &str, keep_full_version: bool) -> String { + let req = Requirement::from_str(value).unwrap(); + let mut result = req.name.to_string(); + if !req.extras.is_empty() { + write!(&mut result, "[").unwrap(); + let extra_count = req.extras.len() - 1; + for (at, extra) in req.extras.iter().enumerate() { + write!(&mut result, "{extra}").unwrap(); + if extra_count != at { + write!(&mut result, ",").unwrap(); + } + } + write!(&mut result, "]").unwrap(); + } + if let Some(version_or_url) = req.version_or_url { + match version_or_url { + VersionOrUrl::VersionSpecifier(v) => { + let extra_count = v.len() - 1; + for (at, spec) in v.iter().enumerate() { + let mut spec_repr = format!("{spec}"); + if !keep_full_version && spec.operator() != &pep440_rs::Operator::TildeEqual { + loop { + let propose = spec_repr.strip_suffix(".0"); + if propose.is_none() { + break; + } + spec_repr = propose.unwrap().to_string(); + } + } + write!(&mut result, "{spec_repr}").unwrap(); + if extra_count != at { + write!(&mut result, ",").unwrap(); + } + } + } + VersionOrUrl::Url(u) => { + write!(&mut result, " @ {u}").unwrap(); + } + } + } + if let Some(marker) = req.marker { + write!(&mut result, "; ").unwrap(); + handle_marker(&marker, &mut result, false); + } + + result +} + +fn handle_marker(marker: &MarkerTree, result: &mut String, nested: bool) { + match marker { + MarkerTree::Expression(e) => { + write!(result, "{}{}{}", e.l_value, e.operator, e.r_value).unwrap(); + } + MarkerTree::And(a) => { + handle_tree(result, nested, a, " and "); + } + MarkerTree::Or(a) => { + handle_tree(result, nested, a, " or "); + } + } +} + +fn handle_tree(result: &mut String, nested: bool, elements: &[MarkerTree], x: &str) { + let len = elements.len() - 1; + if nested && len > 0 { + write!(result, "(").unwrap(); + } + for (at, e) in elements.iter().enumerate() { + handle_marker(e, result, true); + if at != len { + write!(result, "{x}").unwrap(); + } + } + if nested && len > 0 { + write!(result, ")").unwrap(); + } +} + +pub fn get_canonic_requirement_name(value: &str) -> String { + let req = Requirement::from_str(value).unwrap(); + req.name.to_string() +} diff --git a/common/src/string.rs b/common/src/string.rs new file mode 100644 index 0000000..ed722b2 --- /dev/null +++ b/common/src/string.rs @@ -0,0 +1,51 @@ +use taplo::syntax::SyntaxKind::{IDENT, MULTI_LINE_STRING, MULTI_LINE_STRING_LITERAL, STRING, STRING_LITERAL}; +use taplo::syntax::{SyntaxElement, SyntaxKind, SyntaxNode}; + +use crate::create::make_string_node; + +pub fn load_text(value: &str, kind: SyntaxKind) -> String { + let mut chars = value.chars(); + let offset = if [STRING, STRING_LITERAL].contains(&kind) { + 1 + } else if kind == IDENT { + 0 + } else { + 3 + }; + for _ in 0..offset { + chars.next(); + } + for _ in 0..offset { + chars.next_back(); + } + let mut res = chars.as_str().to_string(); + if kind == STRING { + res = res.replace("\\\"", "\""); + } + res +} + +pub fn update_content(entry: &SyntaxNode, transform: F) +where + F: Fn(&str) -> String, +{ + let (mut to_insert, mut count) = (Vec::::new(), 0); + let mut changed = false; + for mut child in entry.children_with_tokens() { + count += 1; + let kind = child.kind(); + if [STRING, STRING_LITERAL, MULTI_LINE_STRING, MULTI_LINE_STRING_LITERAL].contains(&kind) { + let found_str_value = load_text(child.as_token().unwrap().text(), kind); + let output = transform(found_str_value.as_str()); + + changed = output != found_str_value || kind != STRING; + if changed { + child = make_string_node(output.as_str()); + } + } + to_insert.push(child); + } + if changed { + entry.splice_children(0..count, to_insert); + } +} diff --git a/common/src/table.rs b/common/src/table.rs new file mode 100644 index 0000000..cb83532 --- /dev/null +++ b/common/src/table.rs @@ -0,0 +1,318 @@ +use std::cell::{RefCell, RefMut}; +use std::collections::HashMap; +use std::iter::zip; +use std::ops::Index; + +use taplo::syntax::SyntaxKind::{ENTRY, IDENT, KEY, NEWLINE, TABLE_ARRAY_HEADER, TABLE_HEADER, VALUE}; +use taplo::syntax::{SyntaxElement, SyntaxNode}; +use taplo::HashSet; + +use crate::create::{make_empty_newline, make_key, make_newline, make_table_entry}; +use crate::string::load_text; + +#[derive(Debug)] +pub struct Tables { + pub header_to_pos: HashMap>, + pub table_set: Vec>>, +} + +impl Tables { + pub fn get(&self, key: &str) -> Option>>> { + if self.header_to_pos.contains_key(key) { + let mut res = Vec::<&RefCell>>::new(); + for pos in &self.header_to_pos[key] { + res.push(&self.table_set[*pos]); + } + Some(res) + } else { + None + } + } + + pub fn from_ast(root_ast: &SyntaxNode) -> Self { + let mut header_to_pos = HashMap::>::new(); + let mut table_set = Vec::>>::new(); + let entry_set = RefCell::new(Vec::::new()); + let mut table_kind = TABLE_HEADER; + let mut add_to_table_set = |kind| { + let mut entry_set_borrow = entry_set.borrow_mut(); + if !entry_set_borrow.is_empty() { + let table_name = get_table_name(&entry_set_borrow[0]); + let indexes = header_to_pos.entry(table_name).or_default(); + if kind == TABLE_ARRAY_HEADER || (kind == TABLE_HEADER && indexes.is_empty()) { + indexes.push(table_set.len()); + table_set.push(RefCell::new(entry_set_borrow.clone())); + } else if kind == TABLE_HEADER && !indexes.is_empty() { + // join tables + let pos = indexes.first().unwrap(); + let mut res = table_set.index(*pos).borrow_mut(); + let mut new = entry_set_borrow.clone(); + if let Some(last_non_trailing_newline_index) = new.iter().rposition(|x| x.kind() != NEWLINE) { + new.truncate(last_non_trailing_newline_index + 1); + } + if res.last().unwrap().kind() != NEWLINE { + res.push(make_newline()); + } + res.extend( + new.into_iter() + .skip_while(|x| [NEWLINE, TABLE_HEADER].contains(&x.kind())), + ); + } + entry_set_borrow.clear(); + } + }; + for c in root_ast.children_with_tokens() { + if [TABLE_ARRAY_HEADER, TABLE_HEADER].contains(&c.kind()) { + add_to_table_set(table_kind); + table_kind = c.kind(); + } + entry_set.borrow_mut().push(c); + } + add_to_table_set(table_kind); + Self { + header_to_pos, + table_set, + } + } + + pub fn reorder(&self, root_ast: &SyntaxNode, order: &[&str]) { + let mut to_insert = Vec::::new(); + let order = calculate_order(&self.header_to_pos, &self.table_set, order); + let mut next = order.clone(); + if !next.is_empty() { + next.remove(0); + } + next.push(String::new()); + for (name, next_name) in zip(order.iter(), next.iter()) { + for entries in self.get(name).unwrap() { + let got = entries.borrow_mut(); + if !got.is_empty() { + let last = got.last().unwrap(); + if name.is_empty() && last.kind() == NEWLINE && got.len() == 1 { + continue; + } + let mut add = got.clone(); + if get_key(name) != get_key(next_name) { + if last.kind() == NEWLINE { + // replace existing newline to ensure single newline + add.pop(); + } + add.push(make_empty_newline()); + } + to_insert.extend(add); + } + } + } + root_ast.splice_children(0..root_ast.children_with_tokens().count(), to_insert); + } +} +fn calculate_order( + header_to_pos: &HashMap>, + table_set: &[RefCell>], + ordering: &[&str], +) -> Vec { + let max_ordering = ordering.len() * 2; + let key_to_pos = ordering + .iter() + .enumerate() + .map(|(k, v)| (v, k * 2)) + .collect::>(); + + let mut header_pos: Vec<(String, usize)> = header_to_pos + .clone() + .into_iter() + .filter(|(_k, v)| v.iter().any(|p| !table_set.get(*p).unwrap().borrow().is_empty())) + .map(|(k, v)| (k, *v.iter().min().unwrap())) + .collect(); + + header_pos.sort_by_cached_key(|(k, file_pos)| -> (usize, usize) { + let key = get_key(k); + let pos = key_to_pos.get(&key.as_str()); + + ( + if let Some(&pos) = pos { + let offset = usize::from(key != *k); + pos + offset + } else { + max_ordering + }, + *file_pos, + ) + }); + header_pos.into_iter().map(|(k, _)| k).collect() +} + +fn get_key(k: &str) -> String { + let parts: Vec<&str> = k.splitn(3, '.').collect(); + if !parts.is_empty() { + return if parts[0] == "tool" && parts.len() >= 2 { + parts[0..2].join(".") + } else { + String::from(parts[0]) + }; + } + String::from(k) +} + +pub fn reorder_table_keys(table: &mut RefMut>, order: &[&str]) { + let (size, mut to_insert) = (table.len(), Vec::::new()); + let (key_to_position, key_set) = load_keys(table); + let mut handled_positions = HashSet::::new(); + for current_key in order { + let mut matching_keys = key_to_position + .iter() + .filter(|(checked_key, position)| { + !handled_positions.contains(position) + && (current_key == checked_key + || (checked_key.starts_with(current_key) + && checked_key.len() > current_key.len() + && checked_key.chars().nth(current_key.len()).unwrap() == '.')) + }) + .map(|(key, _)| key) + .clone() + .collect::>(); + matching_keys.sort_by_key(|key| key.to_lowercase().replace('"', "")); + for key in matching_keys { + let position = key_to_position[key]; + to_insert.extend(key_set[position].clone()); + handled_positions.insert(position); + } + } + for (position, entries) in key_set.into_iter().enumerate() { + if !handled_positions.contains(&position) { + to_insert.extend(entries); + } + } + table.splice(0..size, to_insert); +} + +fn load_keys(table: &[SyntaxElement]) -> (HashMap, Vec>) { + let mut key_to_pos = HashMap::::new(); + let mut key_set = Vec::>::new(); + let entry_set = RefCell::new(Vec::::new()); + let mut add_to_key_set = |k| { + let mut entry_set_borrow = entry_set.borrow_mut(); + if !entry_set_borrow.is_empty() { + key_to_pos.insert(k, key_set.len()); + key_set.push(entry_set_borrow.clone()); + entry_set_borrow.clear(); + } + }; + let mut key = String::new(); + let mut cutoff = false; + for element in table { + let kind = element.kind(); + if kind == ENTRY { + if cutoff { + add_to_key_set(key.clone()); + cutoff = false; + } + for e in element.as_node().unwrap().children_with_tokens() { + if e.kind() == KEY { + key = e.as_node().unwrap().text().to_string().trim().to_string(); + break; + } + } + } + if [ENTRY, TABLE_HEADER, TABLE_ARRAY_HEADER].contains(&kind) { + cutoff = true; + } + entry_set.borrow_mut().push(element.clone()); + if cutoff && kind == NEWLINE { + add_to_key_set(key.clone()); + cutoff = false; + } + } + add_to_key_set(key); + (key_to_pos, key_set) +} + +pub fn get_table_name(entry: &SyntaxElement) -> String { + if [TABLE_HEADER, TABLE_ARRAY_HEADER].contains(&entry.kind()) { + for child in entry.as_node().unwrap().children_with_tokens() { + if child.kind() == KEY { + return child.as_node().unwrap().text().to_string().trim().to_string(); + } + } + } + String::new() +} + +pub fn for_entries(table: &[SyntaxElement], f: &mut F) +where + F: FnMut(String, &SyntaxNode), +{ + let mut key = String::new(); + for table_entry in table { + if table_entry.kind() == ENTRY { + for entry in table_entry.as_node().unwrap().children_with_tokens() { + if entry.kind() == KEY { + key = entry.as_node().unwrap().text().to_string().trim().to_string(); + } else if entry.kind() == VALUE { + f(key.clone(), entry.as_node().unwrap()); + } + } + } + } +} + +pub fn collapse_sub_tables(tables: &mut Tables, name: &str) { + let h2p = tables.header_to_pos.clone(); + let sub_name_prefix = format!("{name}."); + let sub_table_keys: Vec<&String> = h2p.keys().filter(|s| s.starts_with(sub_name_prefix.as_str())).collect(); + if sub_table_keys.is_empty() { + return; + } + if !tables.header_to_pos.contains_key(name) { + tables + .header_to_pos + .insert(String::from(name), vec![tables.table_set.len()]); + tables.table_set.push(RefCell::new(make_table_entry(name))); + } + let main_positions = tables.header_to_pos[name].clone(); + if main_positions.len() != 1 { + return; + } + let mut main = tables.table_set[*main_positions.first().unwrap()].borrow_mut(); + for key in sub_table_keys { + let sub_positions = tables.header_to_pos[key].clone(); + if sub_positions.len() != 1 { + continue; + } + let mut sub = tables.table_set[*sub_positions.first().unwrap()].borrow_mut(); + let sub_name = key.strip_prefix(sub_name_prefix.as_str()).unwrap(); + let mut header = false; + for child in sub.iter() { + let kind = child.kind(); + if kind == TABLE_HEADER { + header = true; + continue; + } + if header && kind == NEWLINE { + continue; + } + if kind == ENTRY { + let mut to_insert = Vec::::new(); + let child_node = child.as_node().unwrap(); + for mut entry in child_node.children_with_tokens() { + if entry.kind() == KEY { + for array_entry_value in entry.as_node().unwrap().children_with_tokens() { + if array_entry_value.kind() == IDENT { + let txt = load_text(array_entry_value.as_token().unwrap().text(), IDENT); + entry = make_key(format!("{sub_name}.{txt}").as_str()); + break; + } + } + } + to_insert.push(entry); + } + child_node.splice_children(0..to_insert.len(), to_insert); + } + if main.last().unwrap().kind() != NEWLINE { + main.push(make_newline()); + } + main.push(child.clone()); + } + sub.clear(); + } +} diff --git a/common/src/tests/array_tests.rs b/common/src/tests/array_tests.rs new file mode 100644 index 0000000..4439e50 --- /dev/null +++ b/common/src/tests/array_tests.rs @@ -0,0 +1,183 @@ +use indoc::indoc; +use rstest::rstest; +use taplo::formatter::{format_syntax, Options}; +use taplo::parser::parse; +use taplo::syntax::SyntaxKind::{ENTRY, VALUE}; + +use crate::array::{sort, transform}; +use crate::pep508::format_requirement; + +#[rstest] +#[case::strip_micro_no_keep( + indoc ! {r#" + a=["maturin >= 1.5.0"] + "#}, + indoc ! {r#" + a = ["maturin>=1.5"] + "#}, + false +)] +#[case::strip_micro_keep( + indoc ! {r#" + a=["maturin >= 1.5.0"] + "#}, + indoc ! {r#" + a = ["maturin>=1.5.0"] + "#}, + true +)] +#[case::no_change( + indoc ! {r#" + a = [ + "maturin>=1.5.3",# comment here + # a comment afterwards + ] + "#}, + indoc ! {r#" + a = [ + "maturin>=1.5.3", # comment here + # a comment afterwards + ] + "#}, + false +)] +#[case::ignore_non_string( + indoc ! {r#" + a=[{key="maturin>=1.5.0"}] + "#}, + indoc ! {r#" + a = [{ key = "maturin>=1.5.0" }] + "#}, + false +)] +#[case::has_double_quote( + indoc ! {r#" + a=['importlib-metadata>=7.0.0;python_version<"3.8"'] + "#}, + indoc ! {r#" + a = ["importlib-metadata>=7; python_version<'3.8'"] + "#}, + false +)] +fn test_normalize_requirement(#[case] start: &str, #[case] expected: &str, #[case] keep_full_version: bool) { + let root_ast = parse(start).into_syntax().clone_for_update(); + for children in root_ast.children_with_tokens() { + if children.kind() == ENTRY { + for entry in children.as_node().unwrap().children_with_tokens() { + if entry.kind() == VALUE { + transform(entry.as_node().unwrap(), &|s| format_requirement(s, keep_full_version)); + } + } + } + } + let res = format_syntax(root_ast, Options::default()); + assert_eq!(expected, res); +} + +#[rstest] +#[case::empty( + indoc ! {r" + a = [] + "}, + indoc ! {r" + a = [] + "} +)] +#[case::single( + indoc ! {r#" + a = ["A"] + "#}, + indoc ! {r#" + a = ["A"] + "#} +)] +#[case::newline_single( + indoc ! {r#" + a = ["A"] + "#}, + indoc ! {r#" + a = ["A"] + "#} +)] +#[case::newline_single_comment( + indoc ! {r#" + a = [ # comment + "A" + ] + "#}, + indoc ! {r#" + a = [ + # comment + "A", + ] + "#} +)] +#[case::double( + indoc ! {r#" + a = ["A", "B"] + "#}, + indoc ! {r#" + a = ["A", "B"] + "#} +)] +#[case::increasing( + indoc ! {r#" + a=["B", "D", + # C comment + "C", # C trailing + # A comment + "A" # A trailing + # extra + ] # array comment + "#}, + indoc ! {r#" + a = [ + # A comment + "A", # A trailing + "B", + # C comment + "C", # C trailing + "D", + # extra + ] # array comment + "#} +)] +fn test_order_array(#[case] start: &str, #[case] expected: &str) { + let root_ast = parse(start).into_syntax().clone_for_update(); + for children in root_ast.children_with_tokens() { + if children.kind() == ENTRY { + for entry in children.as_node().unwrap().children_with_tokens() { + if entry.kind() == VALUE { + sort(entry.as_node().unwrap(), str::to_lowercase); + } + } + } + } + let opt = Options { + column_width: 120, + ..Options::default() + }; + let res = format_syntax(root_ast, opt); + assert_eq!(res, expected); +} + +#[rstest] +#[case::reorder_no_trailing_comma( + indoc ! {r#"a=["B","A"]"#}, + indoc ! {r#"a=["A","B"]"#} +)] +fn test_reorder_no_trailing_comma(#[case] start: &str, #[case] expected: &str) { + let root_ast = parse(start).into_syntax().clone_for_update(); + for children in root_ast.children_with_tokens() { + if children.kind() == ENTRY { + for entry in children.as_node().unwrap().children_with_tokens() { + if entry.kind() == VALUE { + sort(entry.as_node().unwrap(), str::to_lowercase); + } + } + } + } + let mut res = root_ast.to_string(); + res.retain(|x| !x.is_whitespace()); + assert_eq!(res, expected); +} diff --git a/common/src/tests/mod.rs b/common/src/tests/mod.rs new file mode 100644 index 0000000..80aa749 --- /dev/null +++ b/common/src/tests/mod.rs @@ -0,0 +1,2 @@ +pub mod array_tests; +pub mod pep508_tests; diff --git a/common/src/tests/pep508_tests.rs b/common/src/tests/pep508_tests.rs new file mode 100644 index 0000000..b3fc455 --- /dev/null +++ b/common/src/tests/pep508_tests.rs @@ -0,0 +1,33 @@ +use rstest::rstest; + +use crate::pep508::{format_requirement, get_canonic_requirement_name}; + +#[rstest] +#[case::lowercase("A", "a")] +#[case::replace_dot_with_dash("a.b", "a-b")] +fn test_get_canonic_requirement_name(#[case] start: &str, #[case] expected: &str) { + assert_eq!(get_canonic_requirement_name(start), expected); +} +#[rstest] +#[case::strip_version( + r#"requests [security , tests] >= 2.0.0, == 2.8.* ; (os_name=="a" or os_name=='b') and os_name=='c' and python_version > "3.8""#, + "requests[security,tests]>=2,==2.8.*; (os_name=='a' or os_name=='b') and os_name=='c' and python_version>'3.8'", + false +)] +#[case::keep_version( +r#"requests [security , tests] >= 2.0.0, == 2.8.* ; (os_name=="a" or os_name=='b') and os_name=='c' and python_version > "3.8""#, +"requests[security,tests]>=2.0.0,==2.8.*; (os_name=='a' or os_name=='b') and os_name=='c' and python_version>'3.8'", +true +)] +#[case::do_not_strip_tilda("a~=3.0.0", "a~=3.0.0", false)] +#[case::url( + " pip @ https://github.com/pypa/pip/archive/1.3.1.zip#sha1=da9234ee9982d4bbb3c72346a6de940a148ea686 ", + "pip @ https://github.com/pypa/pip/archive/1.3.1.zip#sha1=da9234ee9982d4bbb3c72346a6de940a148ea686", + true +)] +fn test_format_requirement(#[case] start: &str, #[case] expected: &str, #[case] keep_full_version: bool) { + let got = format_requirement(start, keep_full_version); + assert_eq!(got, expected); + // formatting remains stable + assert_eq!(format_requirement(got.as_str(), keep_full_version), expected); +} diff --git a/ignore-words.txt b/ignore-words.txt new file mode 100644 index 0000000..9ac17d5 --- /dev/null +++ b/ignore-words.txt @@ -0,0 +1 @@ +crate diff --git a/pyproject-fmt/.github/FUNDING.yml b/pyproject-fmt/.github/FUNDING.yml new file mode 100644 index 0000000..423b664 --- /dev/null +++ b/pyproject-fmt/.github/FUNDING.yml @@ -0,0 +1 @@ +tidelift: "pypi/pyproject-fmt-rust" diff --git a/pyproject-fmt/.github/SECURITY.md b/pyproject-fmt/.github/SECURITY.md new file mode 100644 index 0000000..f9506b7 --- /dev/null +++ b/pyproject-fmt/.github/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.0 + | :white_check_mark: | +| < 1.0 | :x: | + +## Reporting a Vulnerability + +To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift +will coordinate the fix and disclosure. diff --git a/pyproject-fmt/.github/dependabot.yml b/pyproject-fmt/.github/dependabot.yml new file mode 100644 index 0000000..1230149 --- /dev/null +++ b/pyproject-fmt/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/pyproject-fmt/.github/release.yml b/pyproject-fmt/.github/release.yml new file mode 100644 index 0000000..9d1e098 --- /dev/null +++ b/pyproject-fmt/.github/release.yml @@ -0,0 +1,5 @@ +changelog: + exclude: + authors: + - dependabot + - pre-commit-ci diff --git a/pyproject-fmt/.github/workflows/check.yaml b/pyproject-fmt/.github/workflows/check.yaml new file mode 100644 index 0000000..66d685b --- /dev/null +++ b/pyproject-fmt/.github/workflows/check.yaml @@ -0,0 +1,93 @@ +name: Check +on: + workflow_dispatch: + push: + branches: ["main"] + tags: ["*"] + pull_request: +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true +jobs: + test: + name: test ${{ matrix.py }} ${{ matrix.os }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + py: + - "3.13" + - "3.12" + - "3.11" + - "3.10" + - "3.9" + os: + - ubuntu-latest + - windows-latest + - macos-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + - name: Install tox + run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv + - name: Install Python + if: matrix.py != '3.13' + run: uv python install --python-preference only-managed ${{ matrix.env }} + - uses: moonrepo/setup-rust@v1 + with: + cache-base: main + bins: cargo-tarpaulin + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: setup test suite + run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.py }} + - name: run test suite + run: tox run --skip-pkg-install -e ${{ matrix.py }} + env: + PYTEST_ADDOPTS: "-vv --durations=20" + + check: + name: tox env ${{ matrix.env }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + env: + - type + - dev + - pkg_meta + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install tox + run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv + - name: Setup test suite + run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.env }} + - name: Run test suite + run: tox run --skip-pkg-install -e ${{ matrix.env }} + env: + PYTEST_ADDOPTS: "-vv --durations=20" + + rust-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Rustfmt Check + uses: actions-rust-lang/rustfmt@v1 + - name: Lint + run: cargo clippy --all-targets -- -D warnings diff --git a/pyproject-fmt/.github/workflows/release.yaml b/pyproject-fmt/.github/workflows/release.yaml new file mode 100644 index 0000000..ca7bdc6 --- /dev/null +++ b/pyproject-fmt/.github/workflows/release.yaml @@ -0,0 +1,135 @@ +name: Build +on: + workflow_dispatch: + push: + branches: ["main"] + tags: ["*"] + pull_request: + schedule: + - cron: "0 8 * * *" +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + linux: + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + platform: + - runner: ubuntu-latest + target: x86_64 + interpreter: "3.8 pypy3.8 pypy3.9 pypy3.10" + - runner: ubuntu-latest + target: x86 + - runner: ubuntu-latest + target: x86_64-unknown-linux-musl + manylinux: musllinux_1_1 + - runner: ubuntu-latest + target: i686-unknown-linux-musl + manylinux: musllinux_1_1 + - runner: ubuntu-latest + target: aarch64 + - runner: ubuntu-latest + target: armv7 + - runner: ubuntu-latest + target: s390x + - runner: ubuntu-latest + target: ppc64le + steps: + - uses: actions/checkout@v4 + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --interpreter ${{ matrix.platform.interpreter || '3.8' }} + sccache: "true" + manylinux: ${{ matrix.platform.manylinux || 'auto' }} + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + windows: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --interpreter ${{ matrix.platform.interpreter || '3.8' }} + sccache: "true" + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + + macos: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-latest + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - uses: actions/checkout@v4 + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --interpreter "3.8 pypy3.8 pypy3.9 pypy3.10" + sccache: "true" + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/project/pyproject-fmt-rust/${{ github.ref_name }} + permissions: + id-token: write + if: "startsWith(github.ref, 'refs/tags/')" + needs: [linux, windows, macos, sdist] + steps: + - uses: actions/download-artifact@v4 + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/pyproject-fmt/.gitignore b/pyproject-fmt/.gitignore new file mode 100644 index 0000000..bb756a2 --- /dev/null +++ b/pyproject-fmt/.gitignore @@ -0,0 +1,13 @@ +*.egg-info/ +.tox/ +.*_cache +__pycache__ +**.pyc +dist + +/target +/pyproject-*.toml +/src/pyproject_fmt/_lib.abi3* +/tarpaulin-report.html +/build_rs_cov.profraw +/.cargo/config.toml diff --git a/pyproject-fmt/.readthedocs.yml b/pyproject-fmt/.readthedocs.yml new file mode 100644 index 0000000..8e210a6 --- /dev/null +++ b/pyproject-fmt/.readthedocs.yml @@ -0,0 +1,9 @@ +version: 2 +build: + os: ubuntu-lts-latest + tools: + python: "3.12" + commands: + - pip install tox-uv + - tox r -e docs -vv --notest + - tox r -e docs --skip-pkg-install -- "${READTHEDOCS_OUTPUT}"/html diff --git a/pyproject-fmt/.rustfmt.toml b/pyproject-fmt/.rustfmt.toml new file mode 100644 index 0000000..7530651 --- /dev/null +++ b/pyproject-fmt/.rustfmt.toml @@ -0,0 +1 @@ +max_width = 120 diff --git a/pyproject-fmt/CHANGELOG.md b/pyproject-fmt/CHANGELOG.md new file mode 100644 index 0000000..0622876 --- /dev/null +++ b/pyproject-fmt/CHANGELOG.md @@ -0,0 +1,452 @@ + + +# [2.3.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.3.1) - 2024-10-14 + +- Remove `tool.ruff.target_version` by [@edgarrmondragon](https://github.com/edgarrmondragon) in + [#268](https://github.com/tox-dev/pyproject-fmt/pull/268) +- Pull in description fix by [@gaborbernat](https://github.com/gaborbernat) in + [#272](https://github.com/tox-dev/pyproject-fmt/pull/272) thanks [@hugovk](https://github.com/hugovk) + + + +# [2.3.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.3.0) - 2024-10-10 + +- Drop 3.8 support and set 3.13 latest stable by [@gaborbernat](https://github.com/gaborbernat) in + [#267](https://github.com/tox-dev/pyproject-fmt/pull/267) + + + +# [2.2.4](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.2.4) - 2024-09-17 + +- Improve the CI by [@gaborbernat](https://github.com/gaborbernat) in + [#257](https://github.com/tox-dev/pyproject-fmt/pull/257) + + + +# [2.2.3](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.2.3) - 2024-09-08 + +- Fix declare 3.13 support by [@gaborbernat](https://github.com/gaborbernat) in + [#253](https://github.com/tox-dev/pyproject-fmt/pull/253) + + + +# [2.2.2](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.2.2) - 2024-09-08 + +- Declare 3.13 support by [@gaborbernat](https://github.com/gaborbernat) in + [#252](https://github.com/tox-dev/pyproject-fmt/pull/252) + + + +# [2.2.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.2.1) - 2024-07-31 + +- Pull in array split fix by [@gaborbernat](https://github.com/gaborbernat) in + [#245](https://github.com/tox-dev/pyproject-fmt/pull/245) + +CC [@flying-sheep](https://github.com/flying-sheep) + + + +# [2.2.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.2.0) - 2024-07-30 + +- Add Philosophy section by [@nathanjmcdougall](https://github.com/nathanjmcdougall) in + [#235](https://github.com/tox-dev/pyproject-fmt/pull/235) +- Allow for reading TOML files from stdin. by [@fniessink](https://github.com/fniessink) in + [#239](https://github.com/tox-dev/pyproject-fmt/pull/239) + + + +# [2.1.4](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.1.4) - 2024-07-03 + +- ci: try uv by [@henryiii](https://github.com/henryiii) in [#225](https://github.com/tox-dev/pyproject-fmt/pull/225) +- fix: new column width by [@henryiii](https://github.com/henryiii) in + [#224](https://github.com/tox-dev/pyproject-fmt/pull/224) +- Fix sorting not using full spec by [@gaborbernat](https://github.com/gaborbernat) in + [#232](https://github.com/tox-dev/pyproject-fmt/pull/232) + + + +# [2.1.3](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.1.3) - 2024-05-21 + +- Fix table expansion leaves behind extra content by [@gaborbernat](https://github.com/gaborbernat) in + [#223](https://github.com/tox-dev/pyproject-fmt/pull/223) + + + +# [2.1.2](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.1.2) - 2024-05-20 + +- Pull in comments misplaced in tables fix by [@gaborbernat](https://github.com/gaborbernat) in + [#221](https://github.com/tox-dev/pyproject-fmt/pull/221) + + + +# [2.1.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.1.1) - 2024-05-15 + +- Pull in empty sub-tables makes formatting unstable fix by [@gaborbernat](https://github.com/gaborbernat) in + [#218](https://github.com/tox-dev/pyproject-fmt/pull/218) + + + +# [2.1.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.1.0) - 2024-05-14 + +- Add API by [@gaborbernat](https://github.com/gaborbernat) in [#216](https://github.com/tox-dev/pyproject-fmt/pull/216) +- Pull in fixes from upstream by [@gaborbernat](https://github.com/gaborbernat) in + [#217](https://github.com/tox-dev/pyproject-fmt/pull/217) + - Format the ruff table by [@gaborbernat](https://github.com/gaborbernat) in + [#14](https://github.com/tox-dev/pyproject-fmt-rust/pull/14) + - Ensure sorting is stable by [@gaborbernat](https://github.com/gaborbernat) in + [#16](https://github.com/tox-dev/pyproject-fmt-rust/pull/16) + - Do not delete array tables by [@gaborbernat](https://github.com/gaborbernat) in + [#17](https://github.com/tox-dev/pyproject-fmt-rust/pull/17) + + + +# [2.0.4](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.0.4) - 2024-05-13 + +- Update README.md by [@gaborbernat](https://github.com/gaborbernat) in + [#203](https://github.com/tox-dev/pyproject-fmt/pull/203) +- Remove outdated YAML example by [@hugovk](https://github.com/hugovk) in + [#204](https://github.com/tox-dev/pyproject-fmt/pull/204) +- lost 'tomli' dependency when python_version < "3.11" by [@gassyfeve](https://github.com/gassyfeve) in + [#209](https://github.com/tox-dev/pyproject-fmt/pull/209) +- Fix table ordering for sub tables and do not version strip on the ~ operator by + [@gaborbernat](https://github.com/gaborbernat) in [#210](https://github.com/tox-dev/pyproject-fmt/pull/210) + + + +# [2.0.3](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.0.3) - 2024-05-11 + +- Pull in upstream fixes, see https://github.com/tox-dev/pyproject-fmt-rust/releases/tag/1.0.4 and update docs by + [@gaborbernat](https://github.com/gaborbernat) in [#202](https://github.com/tox-dev/pyproject-fmt/pull/202) + + + +# [2.0.2](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.0.2) - 2024-05-11 + +- Pass configuration via separate class by [@gaborbernat](https://github.com/gaborbernat) in + [#197](https://github.com/tox-dev/pyproject-fmt/pull/197) + + + +# [2.0.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.0.1) - 2024-05-11 + +- Fix missing dependency by [@gaborbernat](https://github.com/gaborbernat) in + [#195](https://github.com/tox-dev/pyproject-fmt/pull/195) + + + +# [2.0.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/2.0.0) - 2024-05-10 + +- Migrate to version 2 powered by [pyproject-fmt-rust](https://github.com/tox-dev/pyproject-fmt-rust) by + [@gaborbernat](https://github.com/gaborbernat) in [#194](https://github.com/tox-dev/pyproject-fmt/pull/194) + +This introduces multiple changes: + +- the requirements are formatted to be shorter and normalized +- the TOML is now formatted via https://taplo.tamasfe.dev/configuration/formatter-options.html +- will force arrays to be multiline (can use the column_width configuration to trigger this realign less aggressive) +- now supports min python version setting via cli/config +- normalizes project fields to use string instead of string literal +- collapses project sub tables to be inline within the project table +- comments are now attached to the next entry and moved alongside entries during re-ordering/sorting + + + +# [1.8.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.8.0) - 2024-04-17 + +- Enforce single line `description` by [@edgarrmondragon](https://github.com/edgarrmondragon) in + [#184](https://github.com/tox-dev/pyproject-fmt/pull/184) + + + +# [1.7.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.7.0) - 2024-01-22 + +- Configure pyproject-fmt options from `pyproject.toml` file by [@edgarrmondragon](https://github.com/edgarrmondragon) + in [#169](https://github.com/tox-dev/pyproject-fmt/pull/169) + + + +# [1.6.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.6.0) - 2024-01-10 + +- Support adding the classifier for the current Python prerelease by + [@edgarrmondragon](https://github.com/edgarrmondragon) in [#162](https://github.com/tox-dev/pyproject-fmt/pull/162) +- Fix the "Release to PyPI" workflow and add check-jsonschema as a pre-commit hook to prevent future errors by + [@edgarrmondragon](https://github.com/edgarrmondragon) in [#167](https://github.com/tox-dev/pyproject-fmt/pull/167) + + + +# [1.5.3](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.5.3) - 2023-12-02 + +- feat: Add `poetry-dynamic-versioning` build backend by [@edgarrmondragon](https://github.com/edgarrmondragon) in + [#158](https://github.com/tox-dev/pyproject-fmt/pull/158) + + + +# [1.5.2](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.5.2) - 2023-11-29 + +- feat: Add `deptry` tool by [@edgarrmondragon](https://github.com/edgarrmondragon) in + [#157](https://github.com/tox-dev/pyproject-fmt/pull/157) + + + +# [1.5.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.5.1) - 2023-11-09 + + + +# [1.5.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.5.0) - 2023-11-09 + +- fix: add more tools by [@henryiii](https://github.com/henryiii) in + [#154](https://github.com/tox-dev/pyproject-fmt/pull/154) + + + +# [1.4.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.4.1) - 2023-11-01 + +- Allow --check and --keep-full-version, they are not mutually exclusive by + [@adamtheturtle](https://github.com/adamtheturtle) in [#152](https://github.com/tox-dev/pyproject-fmt/pull/152) + + + +# [1.4.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.4.0) - 2023-11-01 + +- Remove normalize_requires method by [@adamtheturtle](https://github.com/adamtheturtle) in + [#142](https://github.com/tox-dev/pyproject-fmt/pull/142) +- Add test for normalize_pep508_array by [@adamtheturtle](https://github.com/adamtheturtle) in + [#143](https://github.com/tox-dev/pyproject-fmt/pull/143) +- Add test for indent at formatter test level by [@adamtheturtle](https://github.com/adamtheturtle) in + [#146](https://github.com/tox-dev/pyproject-fmt/pull/146) +- Test passing indent option at CLI level by [@adamtheturtle](https://github.com/adamtheturtle) in + [#147](https://github.com/tox-dev/pyproject-fmt/pull/147) +- chore: modernize Ruff config by [@henryiii](https://github.com/henryiii) in + [#148](https://github.com/tox-dev/pyproject-fmt/pull/148) +- fix: support more common tools by [@henryiii](https://github.com/henryiii) in + [#149](https://github.com/tox-dev/pyproject-fmt/pull/149) +- Add option to skip normalizing requirement versions by [@adamtheturtle](https://github.com/adamtheturtle) in + [#150](https://github.com/tox-dev/pyproject-fmt/pull/150) + + + +# [1.3.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.3.0) - 2023-10-25 + +- CLI: Accept `--version` argument, to print the package version by [@amotl](https://github.com/amotl) in + [#139](https://github.com/tox-dev/pyproject-fmt/pull/139) +- Fix generating Python version classifiers based on python-requires by [@gaborbernat](https://github.com/gaborbernat) + in [#140](https://github.com/tox-dev/pyproject-fmt/pull/140) + + + +# [1.2.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.2.0) - 2023-10-02 + +- GitHub Actions: Autoupgrade to Py3.12 production release by [@cclauss](https://github.com/cclauss) in + [#131](https://github.com/tox-dev/pyproject-fmt/pull/131) +- \_PY_MAX_VERSION: int = 12 by [@cclauss](https://github.com/cclauss) in + [#132](https://github.com/tox-dev/pyproject-fmt/pull/132) + + + +# [1.1.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.1.0) - 2023-08-28 + +- feat: allow pep440 version specifiers in requires-python by [@MindTooth](https://github.com/MindTooth) in + [#123](https://github.com/tox-dev/pyproject-fmt/pull/123) + + + +# [1.0.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/1.0.0) - 2023-08-22 + +- Ignore failure of tox executable and drop 3.7 by [@gaborbernat](https://github.com/gaborbernat) in + [#121](https://github.com/tox-dev/pyproject-fmt/pull/121) + + + +# [0.13.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.13.1) - 2023-08-11 + +- feat: include tox.ini in sdist by [@mj0nez](https://github.com/mj0nez) in + [#116](https://github.com/tox-dev/pyproject-fmt/pull/116) + + + +# [0.13.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.13.0) - 2023-07-03 + + + +# [0.12.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.12.1) - 2023-06-20 + + + +# [0.12.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.12.0) - 2023-06-15 + +- git ls-files -z -- .github/workflows/check.yml | xargs -0 sed -i 's|3.12.0-alpha.7|3.12.0-beta.1|g' by + [@gaborbernat](https://github.com/gaborbernat) in [#98](https://github.com/tox-dev/pyproject-fmt/pull/98) +- Add ruff by [@gaborbernat](https://github.com/gaborbernat) in + [#100](https://github.com/tox-dev/pyproject-fmt/pull/100) + + + +# [0.11.2](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.11.2) - 2023-05-09 + +- Do not crash on out of order tables by [@gaborbernat](https://github.com/gaborbernat) in + [#96](https://github.com/tox-dev/pyproject-fmt/pull/96) + + + +# [0.11.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.11.1) - 2023-04-28 + +- Keep implementation classifiers by [@gaborbernat](https://github.com/gaborbernat) in + [#91](https://github.com/tox-dev/pyproject-fmt/pull/91) + + + +# [0.11.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.11.0) - 2023-04-28 + +- Add trusted-publish by [@gaborbernat](https://github.com/gaborbernat) in + [#89](https://github.com/tox-dev/pyproject-fmt/pull/89) +- Generate python version classifiers based on python-requires by [@gaborbernat](https://github.com/gaborbernat) in + [#90](https://github.com/tox-dev/pyproject-fmt/pull/90) + + + +# [0.10.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.10.0) - 2023-04-24 + +- Sort classifiers and respect `--indent` by [@hugovk](https://github.com/hugovk) in + [#84](https://github.com/tox-dev/pyproject-fmt/pull/84) + + + +# [0.9.2](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.9.2) - 2023-02-27 + +- Fix unnecessary newline addition on sub-tables by [@adamchainz](https://github.com/adamchainz) in + [#70](https://github.com/tox-dev/pyproject-fmt/pull/70) + + + +# [0.9.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.9.1) - 2023-02-14 + +- Put setuptools_scm after setuptools by [@gaborbernat](https://github.com/gaborbernat) in + [#68](https://github.com/tox-dev/pyproject-fmt/pull/68) + + + +# [0.9.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.9.0) - 2023-02-14 + +- Add tools ordering and separate tables by one line by [@gaborbernat](https://github.com/gaborbernat) in + [#67](https://github.com/tox-dev/pyproject-fmt/pull/67) + + + +# [0.8.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.8.0) - 2023-02-09 + +- Follow the package name normalization spec by [@hugovk](https://github.com/hugovk) in + [#66](https://github.com/tox-dev/pyproject-fmt/pull/66) + + + +# [0.7.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.7.0) - 2023-02-07 + +- add pre-commit snippet to readme by [@RonnyPfannschmidt](https://github.com/RonnyPfannschmidt) in + [#62](https://github.com/tox-dev/pyproject-fmt/pull/62) +- Bump deps and tools by [@gaborbernat](https://github.com/gaborbernat) in + [#63](https://github.com/tox-dev/pyproject-fmt/pull/63) +- add --check by [@bollwyvl](https://github.com/bollwyvl) in [#65](https://github.com/tox-dev/pyproject-fmt/pull/65) + +- [@RonnyPfannschmidt](https://github.com/RonnyPfannschmidt) made their first contribution in + [#62](https://github.com/tox-dev/pyproject-fmt/pull/62) +- [@bollwyvl](https://github.com/bollwyvl) made their first contribution in + [#65](https://github.com/tox-dev/pyproject-fmt/pull/65) + + + +# [0.6.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.6.0) - 2023-01-30 + +- Handle inline tables and change order, so dependencies and optional-dependencies are closer to each other. + + + +# [0.5.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.5.0) - 2023-01-19 + +- Update version of `pyproject-fmt` in example pre-commit hook in docs by [@namurphy](https://github.com/namurphy) in + [#58](https://github.com/tox-dev/pyproject-fmt/pull/58) +- Exclude bots from generated release notes by [@hugovk](https://github.com/hugovk) in + [#61](https://github.com/tox-dev/pyproject-fmt/pull/61) +- Only write to file if changed by [@hugovk](https://github.com/hugovk) in + [#60](https://github.com/tox-dev/pyproject-fmt/pull/60) + + + +# [0.4.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.4.1) - 2022-11-23 + +- Programming Language :: Python :: 3.11 by [@cclauss](https://github.com/cclauss) in + [#47](https://github.com/tox-dev/pyproject-fmt/pull/47) + + + +# [0.4.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.4.0) - 2022-11-23 + +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#38](https://github.com/tox-dev/pyproject-fmt/pull/38) +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#39](https://github.com/tox-dev/pyproject-fmt/pull/39) +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#41](https://github.com/tox-dev/pyproject-fmt/pull/41) +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#42](https://github.com/tox-dev/pyproject-fmt/pull/42) +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#43](https://github.com/tox-dev/pyproject-fmt/pull/43) +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#44](https://github.com/tox-dev/pyproject-fmt/pull/44) +- feat: support multiple "pyproject.toml" files by [@KyleKing](https://github.com/KyleKing) in + [#46](https://github.com/tox-dev/pyproject-fmt/pull/46) + + + +# [0.3.5](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.3.5) - 2022-08-13 + +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#28](https://github.com/tox-dev/pyproject-fmt/pull/28) +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#29](https://github.com/tox-dev/pyproject-fmt/pull/29) +- Support tomlkit 0.11 by [@gaborbernat](https://github.com/gaborbernat) in + [#33](https://github.com/tox-dev/pyproject-fmt/pull/33) +- Bump actions/checkout from 2 to 3 by [@dependabot](https://github.com/dependabot) in + [#35](https://github.com/tox-dev/pyproject-fmt/pull/35) +- Bump actions/setup-python from 2 to 4 by [@dependabot](https://github.com/dependabot) in + [#34](https://github.com/tox-dev/pyproject-fmt/pull/34) + + + +# [Fix project links (0.3.4)](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.3.4) - 2022-06-26 + +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#19](https://github.com/tox-dev/pyproject-fmt/pull/19) +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#20](https://github.com/tox-dev/pyproject-fmt/pull/20) +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#21](https://github.com/tox-dev/pyproject-fmt/pull/21) +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#24](https://github.com/tox-dev/pyproject-fmt/pull/24) +- [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in + [#25](https://github.com/tox-dev/pyproject-fmt/pull/25) + + + +# [Fix help message referring to tox.ini files (0.3.3)](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.3.3) - 2022-03-23 + + + +# [0.3.2](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.3.2) - 2022-03-01 + + + +# [0.3.1](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.3.1) - 2022-02-27 + + + +# [0.3.0](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.3.0) - 2022-02-27 + + + +# [Support build-system (0.2.0)](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.2.0) - 2022-02-21 + + + +# [Base version (0.1.0)](https://github.com/tox-dev/pyproject-fmt/releases/tag/0.1.0) - 2022-02-21 + + diff --git a/pyproject-fmt/Cargo.toml b/pyproject-fmt/Cargo.toml new file mode 100644 index 0000000..8b65374 --- /dev/null +++ b/pyproject-fmt/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "pyproject-fmt" +version = "2.3.1" +description = "Format pyproject.toml files" +repository = "https://github.com/tox-dev/pyproject-fmt" +readme = "README.md" +license = "MIT" +edition = "2021" + +[lib] +name = "_lib" +path = "rust/src/main.rs" +crate-type = ["cdylib"] + +[dependencies] +common = {path = "../common" } +regex = { version = "1.11.0" } +pyo3 = { version = "0.22.5", features = ["abi3-py38"] } # integration with Python + +[features] +extension-module = ["pyo3/extension-module"] +default = ["extension-module"] + +[dev-dependencies] +rstest = { version = "0.23.0" } # parametrized tests +indoc = { version = "2.0.5" } # dedented test cases for literal strings diff --git a/pyproject-fmt/LICENSE.txt b/pyproject-fmt/LICENSE.txt new file mode 100644 index 0000000..3649823 --- /dev/null +++ b/pyproject-fmt/LICENSE.txt @@ -0,0 +1,18 @@ +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/pyproject-fmt/README.md b/pyproject-fmt/README.md new file mode 100644 index 0000000..cfcaa01 --- /dev/null +++ b/pyproject-fmt/README.md @@ -0,0 +1,9 @@ +# pyproject-fmt + +[![PyPI](https://img.shields.io/pypi/v/pyproject-fmt?style=flat-square)](https://pypi.org/project/pyproject-fmt) +[![PyPI - Implementation](https://img.shields.io/pypi/implementation/pyproject-fmt?style=flat-square)](https://pypi.org/project/pyproject-fmt) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyproject-fmt?style=flat-square)](https://pypi.org/project/pyproject-fmt) +[![Downloads](https://static.pepy.tech/badge/pyproject-fmt/month)](https://pepy.tech/project/pyproject-fmt) +[![PyPI - License](https://img.shields.io/pypi/l/pyproject-fmt?style=flat-square)](https://opensource.org/licenses/MIT) +[![Build pyproject-fmt](https://github.com/tox-dev/toml-fmt/actions/workflows/pyproject_fmt_build.yaml/badge.svg)](https://github.com/tox-dev/toml-fmt/actions/workflows/pyproject_fmt_build.yaml) +[![Test pyproject-fmt](https://github.com/tox-dev/toml-fmt/actions/workflows/pyproject_fmt_test.yaml/badge.svg)](https://github.com/tox-dev/toml-fmt/actions/workflows/pyproject_fmt_test.yaml) diff --git a/pyproject-fmt/docs/conf.py b/pyproject-fmt/docs/conf.py new file mode 100644 index 0000000..97ad422 --- /dev/null +++ b/pyproject-fmt/docs/conf.py @@ -0,0 +1,43 @@ +"""Configuration for documentation build.""" # noqa: INP001 + +from __future__ import annotations + +from datetime import datetime, timezone +from importlib.metadata import version as metadata_version + +company, name = "tox-dev", "pyproject-fmt" +ver = metadata_version("pyproject-fmt") +release, version = ver, ".".join(ver.split(".")[:2]) +now = datetime.now(tz=timezone.utc) +copyright = f"2022-{now.year}, {company}" # noqa: A001 +master_doc, source_suffix = "index", ".rst" + +html_theme = "furo" +html_title, html_last_updated_fmt = name, now.isoformat() +pygments_style, pygments_dark_style = "sphinx", "monokai" + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.extlinks", + "sphinx.ext.intersphinx", + "sphinx_argparse_cli", + "sphinx_autodoc_typehints", + "sphinx_copybutton", +] + +exclude_patterns = ["_build", "changelog/*", "_draft.rst"] +autoclass_content, autodoc_member_order, autodoc_typehints = "class", "bysource", "none" +autodoc_default_options = { + "member-order": "bysource", + "undoc-members": True, + "show-inheritance": True, +} + +extlinks = { + "issue": (f"https://github.com/{company}/{name}/issues/%s", "#%s"), + "user": ("https://github.com/%s", "@%s"), + "gh": ("https://github.com/%s", "%s"), +} +intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} +nitpicky = True +nitpick_ignore = [] diff --git a/pyproject-fmt/docs/index.rst b/pyproject-fmt/docs/index.rst new file mode 100644 index 0000000..63d2425 --- /dev/null +++ b/pyproject-fmt/docs/index.rst @@ -0,0 +1,91 @@ +pyproject-fmt +============= + +Apply a consistent format to your ``pyproject.toml`` file with comment support. +See `changelog here `_. + + +Philosophy +---------- +This tool aims to be an *opinionated formatter*, with similar objectives to +`black `_. This means it deliberately does not support +a wide variety of configuration settings. In return, you get consistency, predictability, +and smaller diffs. + +Use +--- + +Via ``CLI`` +~~~~~~~~~~~ + +Use `pipx `_ to install the project: + +.. code-block:: shell + + pipx install pyproject-fmt + + +Via ``pre-commit`` hook +~~~~~~~~~~~~~~~~~~~~~~~ + +See :gh:`pre-commit/pre-commit` for instructions, sample ``.pre-commit-config.yaml``: + +.. code-block:: yaml + + - repo: https://github.com/tox-dev/pyproject-fmt + rev: "2.0.4" + hooks: + - id: pyproject-fmt + +Via Python +~~~~~~~~~~ + +.. automodule:: pyproject_fmt + :members: + +.. toctree:: + :hidden: + + self + +Configuration via file +---------------------- + +The ``tool.pyproject-fmt`` table is used when present in the ``pyproject.toml`` file: + +.. code-block:: toml + + [tool.pyproject-fmt] + + # after how many column width split arrays/dicts into multiple lines, 1 will force always + column_width = 120 + + # how many spaces use for indentation + indent = 2 + + # if false will remove unnecessary trailing ``.0``'s from version specifiers + keep_full_version = false + + # maximum Python version to use when generating version specifiers + max_supported_python = "3.12" + +If not set they will default to values from the CLI, the example above shows the defaults. + +Command line interface +---------------------- +.. sphinx_argparse_cli:: + :module: pyproject_fmt.cli + :func: _build_cli + :prog: pyproject-fmt + :title: + +Python version classifiers +-------------------------- + +This tool will automatically generate the ``Programming Language :: Python :: 3.X`` classifiers for you. To do so it +needs to know the range of Python interpreter versions you support: + +- The lower bound can be set via the ``requires-python`` key in the ``pyproject.toml`` configuration file (defaults to + the oldest non end of line CPython version at the time of the release). +- The upper bound, by default, will assume the latest stable release of CPython at the time of the release, but can be + changed via CLI flag or the config file. diff --git a/pyproject-fmt/pyproject.toml b/pyproject-fmt/pyproject.toml new file mode 100644 index 0000000..377e670 --- /dev/null +++ b/pyproject-fmt/pyproject.toml @@ -0,0 +1,126 @@ +[build-system] +build-backend = "maturin" +requires = [ + "maturin>=1.7.1", +] + +[project] +name = "pyproject-fmt" +description = "Format your pyproject.toml file" +readme = "README.md" +keywords = [ + "format", + "pyproject", +] +license.file = "LICENSE.txt" +authors = [ + { name = "Bernat Gabor", email = "gaborjbernat@gmail.com" }, +] +requires-python = ">=3.9" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dynamic = [ + "version", +] +dependencies = [ +] +urls."Bug Tracker" = "https://github.com/tox-dev/toml-fmt/issues" +urls."Changelog" = "https://github.com/tox-dev/toml-fmt/blob/main/pyproject-fmt/CHANGELOG.md" +urls.Documentation = "https://github.com/tox-dev/toml-fmt/" +urls."Source Code" = "https://github.com/tox-dev/toml-fmt" +scripts.pyproject-fmt = "pyproject_fmt.__main__:run" + +[tool.maturin] +bindings = "pyo3" +manifest-path = "Cargo.toml" +module-name = "pyproject_fmt._lib" +python-source = "src" +strip = true +include = [ + "rust-toolchain.toml", +] + +[tool.cibuildwheel] +skip = [ + "pp*", + "*musl*", +] + +[tool.pyproject-fmt] +max_supported_python = "3.13" + +[tool.pytest] +ini_options.testpaths = [ + "tests", +] + +[tool.coverage] +html.show_contexts = true +html.skip_covered = false +paths.source = [ + "src", + ".tox/*/.venv/lib/*/site-packages", + ".tox\\*\\.venv\\Lib\\site-packages", + ".tox/*/lib/*/site-packages", + ".tox\\*\\Lib\\site-packages", + "**/src", + "**\\src", +] +report.fail_under = 100 +run.parallel = true +run.plugins = [ + "covdefaults", +] + +[tool.mypy] +show_error_codes = true +strict = true + +[dependency-groups] +dev = [ + { include-group = "docs" }, + { include-group = "fix" }, + { include-group = "tasks" }, + { include-group = "test" }, + { include-group = "type" }, +] +docs = [ + "furo>=2024.8.6", + "sphinx>=8.0.2", + "sphinx-argparse-cli>=1.18.2", + "sphinx-autodoc-typehints>=2.4.4", + "sphinx-copybutton>=0.5.2", +] +fix = [ + "pre-commit-uv>=4.1.3", +] +pkg-meta = [ + "check-wheel-contents>=0.6", + "twine>=5.1.1", + "uv>=0.4.17", +] +tasks = [ + "gitpython>=3.1.43", + "pygithub>=2.4", +] +test = [ + "covdefaults>=2.3", + "pytest>=8.3.2", + "pytest-cov>=5", + "pytest-mock>=3.14", +] +type = [ + "mypy==1.11.2", + "types-cachetools>=5.5.0.20240820", + "types-chardet>=5.0.4.6", + { include-group = "test" }, +] diff --git a/pyproject-fmt/rust/src/build_system.rs b/pyproject-fmt/rust/src/build_system.rs new file mode 100644 index 0000000..74708ec --- /dev/null +++ b/pyproject-fmt/rust/src/build_system.rs @@ -0,0 +1,22 @@ +use common::array::{sort, transform}; +use common::pep508::{format_requirement, get_canonic_requirement_name}; +use common::table::{for_entries, reorder_table_keys, Tables}; + +pub fn fix(tables: &Tables, keep_full_version: bool) { + let table_element = tables.get("build-system"); + if table_element.is_none() { + return; + } + let table = &mut table_element.unwrap().first().unwrap().borrow_mut(); + for_entries(table, &mut |key, entry| match key.as_str() { + "requires" => { + transform(entry, &|s| format_requirement(s, keep_full_version)); + sort(entry, |e| get_canonic_requirement_name(e).to_lowercase()); + } + "backend-path" => { + sort(entry, str::to_lowercase); + } + _ => {} + }); + reorder_table_keys(table, &["", "build-backend", "requires", "backend-path"]); +} diff --git a/pyproject-fmt/rust/src/data/ruff-21.expected.toml b/pyproject-fmt/rust/src/data/ruff-21.expected.toml new file mode 100644 index 0000000..ecd99c9 --- /dev/null +++ b/pyproject-fmt/rust/src/data/ruff-21.expected.toml @@ -0,0 +1,14 @@ +[tool.ruff] +lint.select = [ + "ALL", +] +lint.ignore = [ + # We do not annotate the type of 'self'. + "ANN101", +] +# Do not automatically remove commented out code. +# We comment out code during development, and with VSCode auto-save, this code +# is sometimes annoyingly removed. +lint.unfixable = [ + "ERA001", +] diff --git a/pyproject-fmt/rust/src/data/ruff-21.start.toml b/pyproject-fmt/rust/src/data/ruff-21.start.toml new file mode 100644 index 0000000..c756765 --- /dev/null +++ b/pyproject-fmt/rust/src/data/ruff-21.start.toml @@ -0,0 +1,12 @@ +[tool.ruff.lint] +select = ["ALL"] + +ignore = [ + # We do not annotate the type of 'self'. + "ANN101", +] + +# Do not automatically remove commented out code. +# We comment out code during development, and with VSCode auto-save, this code +# is sometimes annoyingly removed. +unfixable = ["ERA001"] diff --git a/pyproject-fmt/rust/src/data/ruff-order.expected.toml b/pyproject-fmt/rust/src/data/ruff-order.expected.toml new file mode 100644 index 0000000..c9f45ab --- /dev/null +++ b/pyproject-fmt/rust/src/data/ruff-order.expected.toml @@ -0,0 +1,198 @@ +[tool.ruff] +required-version = ">=0.0.193" +extend = "../pyproject.toml" +target-version = "py37" +line-length = 120 +indent-width = 2 +tab-size = 2 +builtins = [ + "ALPHA", + "Bar", +] +namespace-packages = [ + "ALPHA", + "Bar", +] +src = [ + "ALPHA", + "Bar", +] +include = [ + "ALPHA", + "Bar", +] +extend-include = [ + "ALPHA", + "Bar", +] +exclude = [ + "ALPHA", + "Bar", +] +extend-exclude = [ + "ALPHA", + "Bar", +] +force-exclude = true +respect-gitignore = false +preview = true +fix = true +unsafe-fixes = true +fix-only = true +show-fixes = true +show-source = true +output-format = "grouped" +cache-dir = "~/a" +format.preview = true +format.indent-style = "tab" +format.quote-style = "single" +format.line-ending = "lf" +format.skip-magic-trailing-comma = true +format.docstring-code-line-length = 60 +format.exclude = [ + "ALPHA", + "Bar", +] +format.docstring-code-format = true +format.extra = true +format.more = true +lint.select = [ + "ALPHA", + "Bar", +] +lint.extend-select = [ + "ALPHA", + "Bar", +] +lint.explicit-preview-rules = true +lint.exclude = [ + "ALPHA", + "Bar", +] +lint.extend-ignore = [ + "ALPHA", + "Bar", +] +lint.per-file-ignores.'Magic.py' = [ + "ALPHA", + "Bar", +] +lint.per-file-ignores."alpha.py" = [ + "ALPHA", + "Bar", +] +lint.extend-per-file-ignores.'Magic.py' = [ + "ALPHA", + "Bar", +] +lint.extend-per-file-ignores."alpha.py" = [ + "ALPHA", + "Bar", +] +lint.fixable = [ + "ALPHA", + "Bar", +] +lint.extend-fixable = [ + "ALPHA", + "Bar", +] +lint.unfixable = [ + "ALPHA", + "Bar", +] +lint.extend-safe-fixes = [ + "ALPHA", + "Bar", +] +lint.extend-unsafe-fixes = [ + "ALPHA", + "Bar", +] +lint.typing-modules = [ + "ALPHA", + "Bar", +] +lint.allowed-confusables = [ + "−", + "∗", + "ρ", +] +lint.dummy-variable-rgx = "^_$" +lint.external = [ + "ALPHA", + "Bar", +] +lint.task-tags = [ + "ALPHA", + "Bar", +] +lint.flake8-annotations.suppress-none-returning = true +lint.flake8-bandit.hardcoded-tmp-directory = [ + "ALPHA", + "Bar", +] +lint.flake8-boolean-trap.extend-allowed-calls = [ + "ALPHA", + "Bar", +] +lint.flake8-bugbear.extend-immutable-calls = [ + "ALPHA", + "Bar", +] +lint.flake8-builtins.builtins-ignorelist = [ + "ALPHA", + "Bar", +] +lint.flake8-comprehensions.allow-dict-calls-with-keyword-arguments = true +lint.flake8-copyright.author = "Ruff" +lint.flake8-copyright.notice-rgx = "(?i)Copyright \\(C\\) \\d{4}" +lint.flake8-errmsg.max-string-length = 20 +lint.flake8-gettext.extend-function-names = [ + "ALPHA", + "Bar", +] +lint.flake8-implicit-str-concat.allow-multiline = false +lint.flake8-import-conventions.aliases.altair = "alt" +lint.flake8-import-conventions.aliases.numpy = "np" +lint.flake8-pytest-style.parametrize-names-type = "list" +lint.flake8-pytest-style.raises-extend-require-match-for = [ + "ALPHA", + "Bar", +] +lint.flake8-quotes.docstring-quotes = "single" +lint.flake8-self.extend-ignore-names = [ + "ALPHA", + "Bar", +] +lint.flake8-tidy-imports.banned-module-level-imports = [ + "ALPHA", + "Bar", +] +lint.flake8-type-checking.exempt-modules = [ + "ALPHA", + "Bar", +] +lint.flake8-unused-arguments.ignore-variadic-names = true +lint.isort.section-order = [ + "Bar", + "ALPHA", +] +lint.mccabe.max-complexity = 5 +lint.pep8-naming.classmethod-decorators = [ + "ALPHA", + "Bar", +] +lint.pycodestyle.max-line-length = 100 +lint.pydocstyle.convention = "google" +lint.pyflakes.extend-generics = [ + "ALPHA", + "Bar", +] +lint.pylint.allow-dunder-method-names = [ + "ALPHA", + "Bar", +] +lint.pyupgrade.keep-runtime-typing = true +lint.extra.ok = 1 +lint.more.ok = 1 diff --git a/pyproject-fmt/rust/src/data/ruff-order.start.toml b/pyproject-fmt/rust/src/data/ruff-order.start.toml new file mode 100644 index 0000000..6d68fc0 --- /dev/null +++ b/pyproject-fmt/rust/src/data/ruff-order.start.toml @@ -0,0 +1,114 @@ +[tool.ruff] +builtins = ['Bar', 'ALPHA'] +cache-dir = '~/a' +exclude = ['Bar', 'ALPHA'] +extend = '../pyproject.toml' +extend-exclude = ['Bar', 'ALPHA'] +extend-include = ['Bar', 'ALPHA'] +fix = true +fix-only = true +force-exclude = true +include = ['Bar', 'ALPHA'] +indent-width = 2 +line-length = 120 +namespace-packages = ['Bar', 'ALPHA'] +output-format = 'grouped' +preview = true +required-version = '>=0.0.193' +respect-gitignore = false +show-fixes = true +show-source = true +src = ['Bar', 'ALPHA'] +tab-size = 2 +target-version = 'py37' +unsafe-fixes = true +[tool.ruff.format] +docstring-code-format = true +docstring-code-line-length = 60 +exclude = ['Bar', 'ALPHA'] +indent-style = 'tab' +line-ending = 'lf' +preview = true +more = true +extra = true +quote-style = 'single' +skip-magic-trailing-comma = true +[tool.ruff.lint] +allowed-confusables = ['−', 'ρ', '∗'] +dummy-variable-rgx = '^_$' +exclude = ['Bar', 'ALPHA'] +explicit-preview-rules = true +extend-fixable = ['Bar', 'ALPHA'] +extend-ignore = ['Bar', 'ALPHA'] +extend-safe-fixes = ['Bar', 'ALPHA'] +extend-select = ['Bar', 'ALPHA'] +extend-unsafe-fixes = ['Bar', 'ALPHA'] +external = ['Bar', 'ALPHA'] +fixable = ['Bar', 'ALPHA'] +select = ['Bar', 'ALPHA'] +task-tags = ['Bar', 'ALPHA'] +typing-modules = ['Bar', 'ALPHA'] +unfixable = ['Bar', 'ALPHA'] +[tool.ruff.lint.extend-per-file-ignores] +'Magic.py' = ['Bar', 'ALPHA'] +"alpha.py" = ['Bar', 'ALPHA'] +[tool.ruff.lint.per-file-ignores] +'Magic.py' = ['Bar', 'ALPHA'] +"alpha.py" = ['Bar', 'ALPHA'] +[tool.ruff.lint.flake8-annotations] +suppress-none-returning = true +[tool.ruff.lint.flake8-bandit] +hardcoded-tmp-directory = ['Bar', 'ALPHA'] +[tool.ruff.lint.flake8-boolean-trap] +extend-allowed-calls = ['Bar', 'ALPHA'] +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = ['Bar', 'ALPHA'] +[tool.ruff.lint.flake8-builtins] +builtins-ignorelist = ['Bar', 'ALPHA'] +[tool.ruff.lint.flake8-comprehensions] +allow-dict-calls-with-keyword-arguments = true +[tool.ruff.lint.flake8-copyright] +author = 'Ruff' +notice-rgx = '(?i)Copyright \\(C\\) \\d{4}' +[tool.ruff.lint.flake8-errmsg] +max-string-length = 20 +[tool.ruff.lint.flake8-gettext] +extend-function-names = ['Bar', 'ALPHA'] +[tool.ruff.lint.flake8-implicit-str-concat] +allow-multiline = false +[tool.ruff.lint.flake8-import-conventions.aliases] +altair = "alt" +numpy = "np" +[tool.ruff.lint.flake8-pytest-style] +parametrize-names-type = 'list' +raises-extend-require-match-for = ['Bar', 'ALPHA'] +[tool.ruff.lint.flake8-quotes] +docstring-quotes = 'single' +[tool.ruff.lint.flake8-self] +extend-ignore-names = ['Bar', 'ALPHA'] +[tool.ruff.lint.flake8-tidy-imports] +banned-module-level-imports = ['Bar', 'ALPHA'] +[tool.ruff.lint.flake8-type-checking] +exempt-modules = ['Bar', 'ALPHA'] +[tool.ruff.lint.flake8-unused-arguments] +ignore-variadic-names = true +[tool.ruff.lint.isort] +section-order = ['Bar', 'ALPHA'] +[tool.ruff.lint.mccabe] +max-complexity = 5 +[tool.ruff.lint.pep8-naming] +classmethod-decorators = ['Bar', 'ALPHA'] +[tool.ruff.lint.pycodestyle] +max-line-length = 100 +[tool.ruff.lint.pydocstyle] +convention = 'google' +[tool.ruff.lint.pyflakes] +extend-generics = ['Bar', 'ALPHA'] +[tool.ruff.lint.pylint] +allow-dunder-method-names = ['Bar', 'ALPHA'] +[tool.ruff.lint.more] +ok = 1 +[tool.ruff.lint.extra] +ok = 1 +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = true diff --git a/pyproject-fmt/rust/src/global.rs b/pyproject-fmt/rust/src/global.rs new file mode 100644 index 0000000..8ffa1e4 --- /dev/null +++ b/pyproject-fmt/rust/src/global.rs @@ -0,0 +1,69 @@ +use common::taplo::rowan::SyntaxNode; +use common::taplo::syntax::Lang; + +use common::table::Tables; + +pub fn reorder_tables(root_ast: &SyntaxNode, tables: &Tables) { + tables.reorder( + root_ast, + &[ + "", + "build-system", + "project", + // Build backends + "tool.poetry", + "tool.poetry-dynamic-versioning", + "tool.pdm", + "tool.setuptools", + "tool.distutils", + "tool.setuptools_scm", + "tool.hatch", + "tool.flit", + "tool.scikit-build", + "tool.meson-python", + "tool.maturin", + "tool.whey", + "tool.py-build-cmake", + "tool.sphinx-theme-builder", + // Builders + "tool.cibuildwheel", + // Formatters and linters + "tool.autopep8", + "tool.black", + "tool.ruff", + "tool.isort", + "tool.flake8", + "tool.pycln", + "tool.nbqa", + "tool.pylint", + "tool.repo-review", + "tool.codespell", + "tool.docformatter", + "tool.pydoclint", + "tool.tomlsort", + "tool.check-manifest", + "tool.check-sdist", + "tool.check-wheel-contents", + "tool.deptry", + "tool.pyproject-fmt", + // Testing + "tool.pytest", + "tool.pytest_env", + "tool.pytest-enabler", + "tool.coverage", + // Runners + "tool.doit", + "tool.spin", + "tool.tox", + // Releasers/bumpers + "tool.bumpversion", + "tool.jupyter-releaser", + "tool.tbump", + "tool.towncrier", + "tool.vendoring", + // Type checking + "tool.mypy", + "tool.pyright", + ], + ); +} diff --git a/pyproject-fmt/rust/src/main.rs b/pyproject-fmt/rust/src/main.rs new file mode 100644 index 0000000..6bdd4a9 --- /dev/null +++ b/pyproject-fmt/rust/src/main.rs @@ -0,0 +1,99 @@ +use std::string::String; + +use common::taplo::formatter::{format_syntax, Options}; +use common::taplo::parser::parse; +use pyo3::prelude::{PyModule, PyModuleMethods}; +use pyo3::{pyclass, pyfunction, pymethods, pymodule, wrap_pyfunction, Bound, PyResult}; + +use crate::global::reorder_tables; +use common::table::Tables; + +mod build_system; +mod project; + +mod global; +mod ruff; +#[cfg(test)] +mod tests; + +#[pyclass(frozen, get_all)] +pub struct Settings { + column_width: usize, + indent: usize, + keep_full_version: bool, + max_supported_python: (u8, u8), + min_supported_python: (u8, u8), +} + +#[pymethods] +impl Settings { + #[new] + #[pyo3(signature = (*, column_width, indent, keep_full_version, max_supported_python, min_supported_python ))] + const fn new( + column_width: usize, + indent: usize, + keep_full_version: bool, + max_supported_python: (u8, u8), + min_supported_python: (u8, u8), + ) -> Self { + Self { + column_width, + indent, + keep_full_version, + max_supported_python, + min_supported_python, + } + } +} + +/// Format toml file +#[must_use] +#[pyfunction] +pub fn format_toml(content: &str, opt: &Settings) -> String { + let root_ast = parse(content).into_syntax().clone_for_update(); + let mut tables = Tables::from_ast(&root_ast); + + build_system::fix(&tables, opt.keep_full_version); + project::fix( + &mut tables, + opt.keep_full_version, + opt.max_supported_python, + opt.min_supported_python, + ); + ruff::fix(&mut tables); + reorder_tables(&root_ast, &tables); + + let options = Options { + align_entries: false, // do not align by = + align_comments: true, // align inline comments + align_single_comments: true, // align comments after entries + array_trailing_comma: true, // ensure arrays finish with trailing comma + array_auto_expand: true, // arrays go to multi line when too long + array_auto_collapse: false, // do not collapse for easier diffs + compact_arrays: false, // leave whitespace + compact_inline_tables: false, // leave whitespace + compact_entries: false, // leave whitespace + column_width: opt.column_width, + indent_tables: false, + indent_entries: false, + inline_table_expand: true, + trailing_newline: true, + allowed_blank_lines: 1, // one blank line to separate + indent_string: " ".repeat(opt.indent), + reorder_keys: false, // respect custom order + reorder_arrays: false, // for natural sorting we need to this ourselves + crlf: false, + }; + format_syntax(root_ast, options) +} + +/// # Errors +/// +/// Will return `PyErr` if an error is raised during formatting. +#[pymodule] +#[pyo3(name = "_lib")] +pub fn _lib(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(format_toml, m)?)?; + m.add_class::()?; + Ok(()) +} diff --git a/pyproject-fmt/rust/src/project.rs b/pyproject-fmt/rust/src/project.rs new file mode 100644 index 0000000..0a27aba --- /dev/null +++ b/pyproject-fmt/rust/src/project.rs @@ -0,0 +1,367 @@ +use std::cell::RefMut; + +use common::taplo::syntax::SyntaxKind::{ + ARRAY, BRACKET_END, BRACKET_START, COMMA, ENTRY, IDENT, INLINE_TABLE, KEY, NEWLINE, STRING, VALUE, +}; +use common::taplo::syntax::{SyntaxElement, SyntaxNode}; +use common::taplo::util::StrExt; +use common::taplo::HashSet; +use regex::Regex; + +use common::array::{sort, transform}; +use common::create::{make_array, make_array_entry, make_comma, make_entry_of_string, make_newline}; +use common::pep508::{format_requirement, get_canonic_requirement_name}; +use common::string::{load_text, update_content}; +use common::table::{collapse_sub_tables, for_entries, reorder_table_keys, Tables}; + +pub fn fix( + tables: &mut Tables, + keep_full_version: bool, + max_supported_python: (u8, u8), + min_supported_python: (u8, u8), +) { + collapse_sub_tables(tables, "project"); + let table_element = tables.get("project"); + if table_element.is_none() { + return; + } + let table = &mut table_element.unwrap().first().unwrap().borrow_mut(); + expand_entry_points_inline_tables(table); + for_entries(table, &mut |key, entry| match key.split('.').next().unwrap() { + "name" => { + update_content(entry, get_canonic_requirement_name); + } + "version" | "readme" | "license-files" | "scripts" | "entry-points" | "gui-scripts" => { + update_content(entry, |s| String::from(s)); + } + "description" => { + update_content(entry, |s| { + s.trim() + .lines() + .map(|part| { + part.trim() + .split(char::is_whitespace) + .filter(|part| !part.trim().is_empty()) + .collect::>() + .join(" ") + .replace(" .", ".") + }) + .collect::>() + .join(" ") + }); + } + "requires-python" => { + update_content(entry, |s| s.split_whitespace().collect()); + } + "dependencies" | "optional-dependencies" => { + transform(entry, &|s| format_requirement(s, keep_full_version)); + sort(entry, |e| { + get_canonic_requirement_name(e).to_lowercase() + " " + &format_requirement(e, keep_full_version) + }); + } + "dynamic" | "keywords" => { + transform(entry, &|s| String::from(s)); + sort(entry, str::to_lowercase); + } + "classifiers" => { + transform(entry, &|s| String::from(s)); + sort(entry, str::to_lowercase); + } + _ => {} + }); + + generate_classifiers(table, max_supported_python, min_supported_python); + for_entries(table, &mut |key, entry| { + if key.as_str() == "classifiers" { + sort(entry, str::to_lowercase); + } + }); + reorder_table_keys( + table, + &[ + "", + "name", + "version", + "description", + "readme", + "keywords", + "license", + "license-files", + "maintainers", + "authors", + "requires-python", + "classifiers", + "dynamic", + "dependencies", + // these go at the end as they may be inline or exploded + "optional-dependencies", + "urls", + "scripts", + "gui-scripts", + "entry-points", + ], + ); +} + +fn expand_entry_points_inline_tables(table: &mut RefMut>) { + let (mut to_insert, mut count, mut key) = (Vec::::new(), 0, String::new()); + for s_table_entry in table.iter() { + count += 1; + if s_table_entry.kind() == ENTRY { + let mut has_inline_table = false; + for s_in_table in s_table_entry.as_node().unwrap().children_with_tokens() { + if s_in_table.kind() == KEY { + key = s_in_table.as_node().unwrap().text().to_string().trim().to_string(); + } else if key.starts_with("entry-points.") && s_in_table.kind() == VALUE { + for s_in_value in s_in_table.as_node().unwrap().children_with_tokens() { + if s_in_value.kind() == INLINE_TABLE { + has_inline_table = true; + for s_in_inline_table in s_in_value.as_node().unwrap().children_with_tokens() { + if s_in_inline_table.kind() == ENTRY { + let mut with_key = String::new(); + for s_in_entry in s_in_inline_table.as_node().unwrap().children_with_tokens() { + if s_in_entry.kind() == KEY { + for s_in_key in s_in_entry.as_node().unwrap().children_with_tokens() { + if s_in_key.kind() == IDENT { + with_key = load_text(s_in_key.as_token().unwrap().text(), IDENT); + with_key = String::from(with_key.strip_quotes()); + break; + } + } + } else if s_in_entry.kind() == VALUE { + for s_in_b_value in s_in_entry.as_node().unwrap().children_with_tokens() { + if s_in_b_value.kind() == STRING { + let value = + load_text(s_in_b_value.as_token().unwrap().text(), STRING); + if to_insert.last().unwrap().kind() != NEWLINE { + to_insert.push(make_newline()); + } + let new_key = format!("{key}.{with_key}"); + let got = make_entry_of_string(&new_key, &value); + to_insert.push(got); + break; + } + } + } + } + } + } + } + } + } + } + if !has_inline_table { + to_insert.push(s_table_entry.clone()); + } + } else { + to_insert.push(s_table_entry.clone()); + } + } + table.splice(0..count, to_insert); +} + +fn generate_classifiers( + table: &mut RefMut>, + max_supported_python: (u8, u8), + min_supported_python: (u8, u8), +) { + let (min, max, omit, classifiers) = + get_python_requires_with_classifier(table, max_supported_python, min_supported_python); + match classifiers { + None => { + let entry = make_array("classifiers"); + generate_classifiers_to_entry(entry.as_node().unwrap(), min, max, &omit, &HashSet::new()); + table.push(entry); + } + Some(c) => { + let mut key_value = String::new(); + for table_row in table.iter() { + if table_row.kind() == ENTRY { + for entry in table_row.as_node().unwrap().children_with_tokens() { + if entry.kind() == KEY { + key_value = entry.as_node().unwrap().text().to_string().trim().to_string(); + } else if entry.kind() == VALUE && key_value == "classifiers" { + generate_classifiers_to_entry(table_row.as_node().unwrap(), min, max, &omit, &c); + } + } + } + } + } + }; +} + +fn generate_classifiers_to_entry( + node: &SyntaxNode, + min: (u8, u8), + max: (u8, u8), + omit: &[u8], + existing: &HashSet, +) { + for array in node.children_with_tokens() { + if array.kind() == VALUE { + for root_value in array.as_node().unwrap().children_with_tokens() { + if root_value.kind() == ARRAY { + let mut must_have: HashSet = HashSet::new(); + must_have.insert(String::from("Programming Language :: Python :: 3 :: Only")); + must_have.extend( + (min.1..=max.1) + .filter(|i| !omit.contains(i)) + .map(|i| format!("Programming Language :: Python :: 3.{i}")), + ); + + let mut count = 0; + let delete = existing + .iter() + .filter(|e| e.starts_with("Programming Language :: Python :: 3") && !must_have.contains(*e)) + .collect::>(); + let mut to_insert = Vec::::new(); + let mut delete_mode = false; + for array_entry in root_value.as_node().unwrap().children_with_tokens() { + count += 1; + let kind = array_entry.kind(); + if delete_mode & [NEWLINE, BRACKET_END].contains(&kind) { + delete_mode = false; + if kind == NEWLINE { + continue; + } + } else if kind == VALUE { + for array_entry_value in array_entry.as_node().unwrap().children_with_tokens() { + if array_entry_value.kind() == STRING { + let txt = load_text(array_entry_value.as_token().unwrap().text(), STRING); + delete_mode = delete.contains(&txt); + if delete_mode { + // delete from previous comma/start until next newline + let mut remove_count = to_insert.len(); + for (at, v) in to_insert.iter().rev().enumerate() { + if [COMMA, BRACKET_START].contains(&v.kind()) { + remove_count = at; + for (i, e) in to_insert.iter().enumerate().skip(to_insert.len() - at) { + if e.kind() == NEWLINE { + remove_count = i + 1; + break; + } + } + break; + } + } + to_insert.truncate(remove_count); + } + break; + } + } + } + if !delete_mode { + to_insert.push(array_entry); + } + } + let to_add: HashSet<_> = must_have.difference(existing).collect(); + if !to_add.is_empty() { + // make sure we have a comma + let mut trail_at = 0; + for (at, v) in to_insert.iter().rev().enumerate() { + trail_at = to_insert.len() - at; + if v.kind() == COMMA { + for (i, e) in to_insert.iter().enumerate().skip(trail_at) { + if e.kind() == NEWLINE || e.kind() == BRACKET_END { + trail_at = i; + break; + } + } + break; + } else if v.kind() == BRACKET_START { + break; + } else if v.kind() == VALUE { + to_insert.insert(trail_at, make_comma()); + trail_at += 1; + break; + } + } + let trail = to_insert.split_off(trail_at); + for add in to_add { + to_insert.push(make_array_entry(add)); + to_insert.push(make_comma()); + } + to_insert.extend(trail); + } + root_value.as_node().unwrap().splice_children(0..count, to_insert); + } + } + } + } +} + +type MaxMinPythonWithClassifier = ((u8, u8), (u8, u8), Vec, Option>); + +fn get_python_requires_with_classifier( + table: &[SyntaxElement], + max_supported_python: (u8, u8), + min_supported_python: (u8, u8), +) -> MaxMinPythonWithClassifier { + let mut classifiers: Option> = None; + let mut mins: Vec = vec![]; + let mut maxs: Vec = vec![]; + let mut omit: Vec = vec![]; + assert_eq!(max_supported_python.0, 3, "for now only Python 3 supported"); + assert_eq!(min_supported_python.0, 3, "for now only Python 3 supported"); + + for_entries(table, &mut |key, entry| { + if key == "requires-python" { + for child in entry.children_with_tokens() { + if child.kind() == STRING { + let found_str_value = load_text(child.as_token().unwrap().text(), STRING); + let re = Regex::new(r"^(?<|<=|==|!=|>=|>)3[.](?\d+)").unwrap(); + for part in found_str_value.split(',') { + let capture = re.captures(part); + if capture.is_some() { + let caps = capture.unwrap(); + let minor = caps["minor"].parse::().unwrap(); + match &caps["op"] { + "==" => { + mins.push(minor); + maxs.push(minor); + } + ">=" => { + mins.push(minor); + } + ">" => { + mins.push(minor + 1); + } + "<=" => { + maxs.push(minor); + } + "<" => { + maxs.push(minor - 1); + } + "!=" => { + omit.push(minor); + } + _ => {} + } + } + } + } + } + } else if key == "classifiers" { + for child in entry.children_with_tokens() { + if child.kind() == ARRAY { + let mut found_elements = HashSet::::new(); + for array in child.as_node().unwrap().children_with_tokens() { + if array.kind() == VALUE { + for value in array.as_node().unwrap().children_with_tokens() { + if value.kind() == STRING { + let found = value.as_token().unwrap().text(); + let found_str_value: String = String::from(&found[1..found.len() - 1]); + found_elements.insert(found_str_value); + } + } + } + } + classifiers = Some(found_elements); + } + } + } + }); + let min_py = (3, *mins.iter().max().unwrap_or(&min_supported_python.1)); + let max_py = (3, *maxs.iter().min().unwrap_or(&max_supported_python.1)); + (min_py, max_py, omit, classifiers) +} diff --git a/pyproject-fmt/rust/src/ruff.rs b/pyproject-fmt/rust/src/ruff.rs new file mode 100644 index 0000000..d4da1a5 --- /dev/null +++ b/pyproject-fmt/rust/src/ruff.rs @@ -0,0 +1,189 @@ +use common::array::{sort, transform}; +use common::string::update_content; +use common::table::{collapse_sub_tables, for_entries, reorder_table_keys, Tables}; + +#[allow(clippy::too_many_lines)] +pub fn fix(tables: &mut Tables) { + collapse_sub_tables(tables, "tool.ruff"); + let table_element = tables.get("tool.ruff"); + if table_element.is_none() { + return; + } + let table = &mut table_element.unwrap().first().unwrap().borrow_mut(); + for_entries(table, &mut |key, entry| match key.as_str() { + "target-version" + | "cache-dir" + | "extend" + | "required-version" + | "output-format" + | "format.indent-style" + | "format.line-ending" + | "format.quote-style" + | "lint.dummy-variable-rgx" + | "lint.flake8-copyright.author" + | "lint.flake8-copyright.notice-rgx" + | "lint.flake8-pytest-style.parametrize-names-type" + | "lint.flake8-pytest-style.parametrize-values-row-type" + | "lint.flake8-pytest-style.parametrize-values-type" + | "lint.flake8-quotes.docstring-quotes" + | "lint.flake8-quotes.multiline-quotes" + | "lint.flake8-quotes.inline-quotes" + | "lint.flake8-tidy-imports.ban-relative-imports" + | "lint.isort.known-first-party" + | "lint.isort.known-third-party" + | "lint.isort.relative-imports-order" + | "lint.pydocstyle.convention" => { + update_content(entry, |s| String::from(s)); + } + "exclude" + | "extend-exclude" + | "builtins" + | "include" + | "extend-include" + | "namespace-packages" + | "src" + | "format.exclude" + | "lint.allowed-confusables" + | "lint.exclude" + | "lint.extend-fixable" + | "lint.extend-ignore" + | "lint.extend-safe-fixes" + | "lint.extend-select" + | "lint.extend-unsafe-fixes" + | "lint.external" + | "lint.fixable" + | "lint.ignore" + | "lint.logger-objects" + | "lint.select" + | "lint.task-tags" + | "lint.typing-modules" + | "lint.unfixable" + | "lint.flake8-bandit.hardcoded-tmp-directory" + | "lint.flake8-bandit.hardcoded-tmp-directory-extend" + | "lint.flake8-boolean-trap.extend-allowed-calls" + | "lint.flake8-bugbear.extend-immutable-calls" + | "lint.flake8-builtins.builtins-ignorelist" + | "lint.flake8-gettext.extend-function-names" + | "lint.flake8-gettext.function-names" + | "lint.flake8-import-conventions.banned-from" + | "lint.flake8-pytest-style.raises-extend-require-match-for" + | "lint.flake8-pytest-style.raises-require-match-for" + | "lint.flake8-self.extend-ignore-names" + | "lint.flake8-self.ignore-names" + | "lint.flake8-tidy-imports.banned-module-level-imports" + | "lint.flake8-type-checking.exempt-modules" + | "lint.flake8-type-checking.runtime-evaluated-base-classes" + | "lint.flake8-type-checking.runtime-evaluated-decorators" + | "lint.isort.constants" + | "lint.isort.default-section" + | "lint.isort.extra-standard-library" + | "lint.isort.forced-separate" + | "lint.isort.no-lines-before" + | "lint.isort.required-imports" + | "lint.isort.single-line-exclusions" + | "lint.isort.variables" + | "lint.pep8-naming.classmethod-decorators" + | "lint.pep8-naming.extend-ignore-names" + | "lint.pep8-naming.ignore-names" + | "lint.pep8-naming.staticmethod-decorators" + | "lint.pydocstyle.ignore-decorators" + | "lint.pydocstyle.property-decorators" + | "lint.pyflakes.extend-generics" + | "lint.pylint.allow-dunder-method-names" + | "lint.pylint.allow-magic-value-types" => { + transform(entry, &|s| String::from(s)); + sort(entry, str::to_lowercase); + } + "lint.isort.section-order" => { + transform(entry, &|s| String::from(s)); + } + _ => { + if key.starts_with("lint.extend-per-file-ignores.") || key.starts_with("lint.per-file-ignores.") { + transform(entry, &|s| String::from(s)); + sort(entry, str::to_lowercase); + } + } + }); + reorder_table_keys( + table, + &[ + "", + "required-version", + "extend", + "target-version", + "line-length", + "indent-width", + "tab-size", + "builtins", + "namespace-packages", + "src", + "include", + "extend-include", + "exclude", + "extend-exclude", + "force-exclude", + "respect-gitignore", + "preview", + "fix", + "unsafe-fixes", + "fix-only", + "show-fixes", + "show-source", + "output-format", + "cache-dir", + "format.preview", + "format.indent-style", + "format.quote-style", + "format.line-ending", + "format.skip-magic-trailing-comma", + "format.docstring-code-line-length", + "format.docstring-code-format ", + "format.exclude", + "format", + "lint.select", + "lint.extend-select", + "lint.ignore", + "lint.explicit-preview-rules", + "lint.exclude", + "lint.extend-ignore", + "lint.per-file-ignores", + "lint.extend-per-file-ignores", + "lint.fixable", + "lint.extend-fixable", + "lint.unfixable", + "lint.extend-safe-fixes", + "lint.extend-unsafe-fixes", + "lint.typing-modules", + "lint.allowed-confusables", + "lint.dummy-variable-rgx", + "lint.external", + "lint.task-tags", + "lint.flake8-annotations", + "lint.flake8-bandit", + "lint.flake8-boolean-trap", + "lint.flake8-bugbear", + "lint.flake8-builtins", + "lint.flake8-comprehensions", + "lint.flake8-copyright", + "lint.flake8-errmsg", + "lint.flake8-gettext", + "lint.flake8-implicit-str-concat", + "lint.flake8-import-conventions", + "lint.flake8-pytest-style", + "lint.flake8-quotes", + "lint.flake8-self", + "lint.flake8-tidy-imports", + "lint.flake8-type-checking", + "lint.flake8-unused-arguments", + "lint.isort", + "lint.mccabe", + "lint.pep8-naming", + "lint.pycodestyle", + "lint.pydocstyle", + "lint.pyflakes", + "lint.pylint", + "lint.pyupgrade", + "lint", + ], + ); +} diff --git a/pyproject-fmt/rust/src/tests/build_systems_tests.rs b/pyproject-fmt/rust/src/tests/build_systems_tests.rs new file mode 100644 index 0000000..3ab6750 --- /dev/null +++ b/pyproject-fmt/rust/src/tests/build_systems_tests.rs @@ -0,0 +1,90 @@ +use common::taplo::formatter::{format_syntax, Options}; +use common::taplo::parser::parse; +use common::taplo::syntax::SyntaxElement; +use indoc::indoc; +use rstest::rstest; + +use crate::build_system::fix; +use common::table::Tables; + +fn evaluate(start: &str, keep_full_version: bool) -> String { + let root_ast = parse(start).into_syntax().clone_for_update(); + let count = root_ast.children_with_tokens().count(); + let tables = Tables::from_ast(&root_ast); + fix(&tables, keep_full_version); + let entries = tables + .table_set + .iter() + .flat_map(|e| e.borrow().clone()) + .collect::>(); + root_ast.splice_children(0..count, entries); + let opt = Options { + column_width: 1, + ..Options::default() + }; + format_syntax(root_ast, opt) +} + +#[rstest] +#[case::no_build_system( + indoc ! {r""}, + "\n", + false +)] +#[case::build_system_requires_no_keep( + indoc ! {r#" + [build-system] + requires=["a>=1.0.0", "b.c>=1.5.0"] + "#}, + indoc ! {r#" + [build-system] + requires = [ + "a>=1", + "b-c>=1.5", + ] + "#}, + false +)] +#[case::build_system_requires_keep( + indoc ! {r#" + [build-system] + requires=["a>=1.0.0", "b.c>=1.5.0"] + "#}, + indoc ! {r#" + [build-system] + requires = [ + "a>=1.0.0", + "b-c>=1.5.0", + ] + "#}, + true +)] +#[case::join( + indoc ! {r#" + [build-system] + requires=["a"] + [build-system] + build-backend = "hatchling.build" + [[build-system.a]] + name = "Hammer" + [[build-system.a]] # empty table within the array + [[build-system.a]] + name = "Nail" + "#}, + indoc ! {r#" + [build-system] + build-backend = "hatchling.build" + requires = [ + "a", + ] + [[build-system.a]] + name = "Hammer" + [[build-system.a]] # empty table within the array + [[build-system.a]] + name = "Nail" + "#}, + false +)] +fn test_format_build_systems(#[case] start: &str, #[case] expected: &str, #[case] keep_full_version: bool) { + assert_eq!(evaluate(start, keep_full_version), expected); +} diff --git a/pyproject-fmt/rust/src/tests/global_tests.rs b/pyproject-fmt/rust/src/tests/global_tests.rs new file mode 100644 index 0000000..f9a84ca --- /dev/null +++ b/pyproject-fmt/rust/src/tests/global_tests.rs @@ -0,0 +1,100 @@ +use common::taplo::formatter::{format_syntax, Options}; +use common::taplo::parser::parse; +use indoc::indoc; +use rstest::rstest; + +use crate::global::reorder_tables; +use common::table::Tables; + +#[rstest] +#[case::reorder( + indoc ! {r#" + # comment + a= "b" + [project] + name="alpha" + dependencies=["e"] + [build-system] + build-backend="backend" + requires=["c", "d"] + [tool.mypy] + mk="mv" + [tool.ruff.test] + mrt="vrt" + [extra] + ek = "ev" + [tool.undefined] + mu="mu" + [tool.ruff] + mr="vr" + [demo] + ed = "ed" + [tool.coverage.report] + cd="de" + [tool.coverage] + aa = "bb" + [tool.coverage.paths] + ab="bc" + [tool.coverage.run] + ef="fg" + [tool.pytest] + mk="mv" + "#}, + indoc ! {r#" + # comment + a = "b" + + [build-system] + build-backend = "backend" + requires = [ + "c", + "d", + ] + + [project] + name = "alpha" + dependencies = [ + "e", + ] + + [tool.ruff] + mr = "vr" + [tool.ruff.test] + mrt = "vrt" + + [tool.pytest] + mk = "mv" + + [tool.coverage] + aa = "bb" + [tool.coverage.report] + cd = "de" + [tool.coverage.paths] + ab = "bc" + [tool.coverage.run] + ef = "fg" + + [tool.mypy] + mk = "mv" + + [extra] + ek = "ev" + + [tool.undefined] + mu = "mu" + + [demo] + ed = "ed" + "#}, +)] +fn test_reorder_table(#[case] start: &str, #[case] expected: &str) { + let root_ast = parse(start).into_syntax().clone_for_update(); + let tables = Tables::from_ast(&root_ast); + reorder_tables(&root_ast, &tables); + let opt = Options { + column_width: 1, + ..Options::default() + }; + let got = format_syntax(root_ast, opt); + assert_eq!(got, expected); +} diff --git a/pyproject-fmt/rust/src/tests/main_tests.rs b/pyproject-fmt/rust/src/tests/main_tests.rs new file mode 100644 index 0000000..4f8b82c --- /dev/null +++ b/pyproject-fmt/rust/src/tests/main_tests.rs @@ -0,0 +1,259 @@ +use std::fs::read_to_string; +use std::path::{Path, PathBuf}; + +use indoc::indoc; +use rstest::{fixture, rstest}; + +use crate::{format_toml, Settings}; + +#[rstest] +#[case::simple( + indoc ! {r#" + # comment + a= "b" + [project] + name="alpha" + dependencies=[" e >= 1.5.0"] + [build-system] + build-backend="backend" + requires=[" c >= 1.5.0", "d == 2.0.0"] + [tool.mypy] + mk="mv" + "#}, + indoc ! {r#" + # comment + a = "b" + + [build-system] + build-backend = "backend" + requires = [ + "c>=1.5", + "d==2", + ] + + [project] + name = "alpha" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + ] + dependencies = [ + "e>=1.5", + ] + + [tool.mypy] + mk = "mv" + "#}, + 2, + false, + (3, 13), +)] +#[case::empty( + indoc ! {r""}, + "\n", + 2, + true, + (3, 13) +)] +#[case::scripts( + indoc ! {r#" + [project.scripts] + c = "d" + a = "b" + "#}, + indoc ! {r#" + [project] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + scripts.a = "b" + scripts.c = "d" + "#}, + 2, + true, + (3, 9) +)] +#[case::subsubtable( + indoc ! {r" + [project] + [tool.coverage.report] + a = 2 + [tool.coverage] + a = 0 + [tool.coverage.paths] + a = 1 + [tool.coverage.run] + a = 3 + "}, + indoc ! {r#" + [project] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + + [tool.coverage] + a = 0 + [tool.coverage.report] + a = 2 + [tool.coverage.paths] + a = 1 + [tool.coverage.run] + a = 3 + "#}, + 2, + true, + (3, 9) +)] +#[case::array_of_tables( + indoc ! {r#" + [tool.commitizen] + name = "cz_customize" + + [tool.commitizen.customize] + message_template = "" + + [[tool.commitizen.customize.questions]] + type = "list" + [[tool.commitizen.customize.questions]] + type = "input" + "#}, + indoc ! {r#" + [tool.commitizen] + name = "cz_customize" + + [tool.commitizen.customize] + message_template = "" + + [[tool.commitizen.customize.questions]] + type = "list" + + [[tool.commitizen.customize.questions]] + type = "input" + "#}, + 2, + true, + (3, 9) +)] +#[case::unstable_issue_18( + indoc ! {r#" + [project] + requires-python = "==3.12" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + ] + [project.urls] + Source = "https://github.com/VWS-Python/vws-python-mock" + + [tool.setuptools] + zip-safe = false + "#}, + indoc ! {r#" + [project] + requires-python = "==3.12" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + ] + urls.Source = "https://github.com/VWS-Python/vws-python-mock" + + [tool.setuptools] + zip-safe = false + "#}, + 2, + true, + (3, 9) +)] +fn test_format_toml( + #[case] start: &str, + #[case] expected: &str, + #[case] indent: usize, + #[case] keep_full_version: bool, + #[case] max_supported_python: (u8, u8), +) { + let settings = Settings { + column_width: 1, + indent, + keep_full_version, + max_supported_python, + min_supported_python: (3, 9), + }; + let got = format_toml(start, &settings); + assert_eq!(got, expected); + let second = format_toml(got.as_str(), &settings); + assert_eq!(second, got); +} + +#[fixture] +fn data() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("rust") + .join("src") + .join("data") +} + +#[rstest] +fn test_issue_24(data: PathBuf) { + let start = read_to_string(data.join("ruff-order.start.toml")).unwrap(); + let settings = Settings { + column_width: 1, + indent: 2, + keep_full_version: false, + max_supported_python: (3, 9), + min_supported_python: (3, 9), + }; + let got = format_toml(start.as_str(), &settings); + let expected = read_to_string(data.join("ruff-order.expected.toml")).unwrap(); + assert_eq!(got, expected); + let second = format_toml(got.as_str(), &settings); + assert_eq!(second, got); +} + +/// Test that the column width is respected, +/// and that arrays are neither exploded nor collapsed without reason +#[rstest] +fn test_column_width() { + let start = indoc! {r#" + [build-system] + build-backend = "backend" + requires = ["c>=1.5", "d == 2" ] + + [project] + name = "beta" + dependencies = [ + "e>=1.5", + ] + "#}; + let settings = Settings { + column_width: 80, + indent: 4, + keep_full_version: false, + max_supported_python: (3, 13), + min_supported_python: (3, 13), + }; + let got = format_toml(start, &settings); + let expected = indoc! {r#" + [build-system] + build-backend = "backend" + requires = [ "c>=1.5", "d==2" ] + + [project] + name = "beta" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.13", + ] + dependencies = [ + "e>=1.5", + ] + "#}; + assert_eq!(got, expected); + let second = format_toml(got.as_str(), &settings); + assert_eq!(second, got); +} diff --git a/pyproject-fmt/rust/src/tests/mod.rs b/pyproject-fmt/rust/src/tests/mod.rs new file mode 100644 index 0000000..11e29e4 --- /dev/null +++ b/pyproject-fmt/rust/src/tests/mod.rs @@ -0,0 +1,5 @@ +mod build_systems_tests; +mod global_tests; +mod main_tests; +mod project_tests; +mod ruff_tests; diff --git a/pyproject-fmt/rust/src/tests/project_tests.rs b/pyproject-fmt/rust/src/tests/project_tests.rs new file mode 100644 index 0000000..fb9f254 --- /dev/null +++ b/pyproject-fmt/rust/src/tests/project_tests.rs @@ -0,0 +1,520 @@ +use common::taplo::formatter::{format_syntax, Options}; +use common::taplo::parser::parse; +use common::taplo::syntax::SyntaxElement; +use indoc::indoc; +use rstest::rstest; + +use crate::project::fix; +use common::table::Tables; + +fn evaluate(start: &str, keep_full_version: bool, max_supported_python: (u8, u8)) -> String { + let root_ast = parse(start).into_syntax().clone_for_update(); + let count = root_ast.children_with_tokens().count(); + let mut tables = Tables::from_ast(&root_ast); + fix(&mut tables, keep_full_version, max_supported_python, (3, 9)); + let entries = tables + .table_set + .iter() + .flat_map(|e| e.borrow().clone()) + .collect::>(); + root_ast.splice_children(0..count, entries); + let opt = Options { + column_width: 1, + ..Options::default() + }; + format_syntax(root_ast, opt) +} + +#[rstest] +#[case::no_project( + indoc ! {r""}, + "\n", + false, + (3, 9), +)] +#[case::project_requires_no_keep( + indoc ! {r#" + [project] + dependencies=["a>=1.0.0", "b.c>=1.5.0"] + "#}, + indoc ! {r#" + [project] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + dependencies = [ + "a>=1", + "b-c>=1.5", + ] + "#}, + false, + (3, 9), +)] +#[case::project_requires_keep( + indoc ! {r#" + [project] + dependencies=["a>=1.0.0", "b.c>=1.5.0"] + "#}, + indoc ! {r#" + [project] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + dependencies = [ + "a>=1.0.0", + "b-c>=1.5.0", + ] + "#}, + true, + (3, 9), +)] +#[case::project_requires_ge( + indoc ! {r#" + [project] + requires-python = " >= 3.9" + classifiers = [ + # comment license inline 1 + # comment license inline 2 + "License :: OSI Approved :: MIT License", # comment license post + # comment 3.12 inline 1 + # comment 3.12 inline 2 + "Programming Language :: Python :: 3.12", # comment 3.12 post + # comment 3.10 inline + "Programming Language :: Python :: 3.10" # comment 3.10 post + # extra 1 + # extra 2 + # extra 3 + ] + "#}, + indoc ! {r#" + [project] + requires-python = ">=3.9" + classifiers = [ + # comment license inline 1 + # comment license inline 2 + "License :: OSI Approved :: MIT License", # comment license post + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + # comment 3.10 inline + "Programming Language :: Python :: 3.10", # comment 3.10 post + # extra 1 + # extra 2 + # extra 3 + ] + "#}, + true, + (3, 10), +)] +#[case::project_requires_gt( + indoc ! {r#" + [project] + requires-python = " > 3.8" + "#}, + indoc ! {r#" + [project] + requires-python = ">3.8" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + "#}, + true, + (3, 9), +)] +#[case::project_requires_eq( + indoc ! {r#" + [project] + requires-python = " == 3.12" + "#}, + indoc ! {r#" + [project] + requires-python = "==3.12" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + ] + "#}, + true, + (3, 9), +)] +#[case::project_sort_keywords( + indoc ! {r#" + [project] + keywords = ["b", "A", "a-c", " c"] + "#}, + indoc ! {r#" + [project] + keywords = [ + " c", + "A", + "a-c", + "b", + ] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + "#}, + true, + (3, 9), +)] +#[case::project_sort_dynamic( + indoc ! {r#" + [project] + dynamic = ["b", "A", "a-c", " c", "a10", "a2"] + "#}, + indoc ! {r#" + [project] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + dynamic = [ + " c", + "A", + "a-c", + "a2", + "a10", + "b", + ] + "#}, + true, + (3, 9), +)] +#[case::project_name_norm( + indoc ! {r#" + [project] + name = "a.b.c" + "#}, + indoc ! {r#" + [project] + name = "a-b-c" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + "#}, + true, + (3, 9), +)] +#[case::project_name_literal( + indoc ! {r" + [project] + name = 'a.b.c' + "}, + indoc ! {r#" + [project] + name = "a-b-c" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + "#}, + true, + (3, 9), +)] +#[case::project_requires_gt_old( + indoc ! {r#" + [project] + requires-python = " > 3.7" + "#}, + indoc ! {r#" + [project] + requires-python = ">3.7" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + ] + "#}, + true, + (3, 9), +)] +#[case::project_requires_range( + indoc ! {r#" + [project] + requires-python=">=3.7,<3.13" + "#}, + indoc ! {r#" + [project] + requires-python = ">=3.7,<3.13" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ] + "#}, + true, + (3, 9), +)] +#[case::project_requires_high_range( + indoc ! {r#" + [project] + requires-python = "<=3.13,>3.10" + "#}, + indoc ! {r#" + [project] + requires-python = "<=3.13,>3.10" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + ] + "#}, + true, + (3, 9), +)] +#[case::project_requires_range_neq( + indoc ! {r#" + [project] + requires-python = "<=3.10,!=3.9,>=3.8" + "#}, + indoc ! {r#" + [project] + requires-python = "<=3.10,!=3.9,>=3.8" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.10", + ] + "#}, + true, + (3, 13), +)] +#[case::project_description_whitespace( + "[project]\ndescription = ' A magic stuff \t is great\t\t.\r\n Like really .\t\'\nrequires-python = '==3.12'", + indoc ! {r#" + [project] + description = "A magic stuff is great. Like really." + requires-python = "==3.12" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + ] + "#}, + true, + (3, 13), +)] +#[case::project_description_multiline( + indoc ! {r#" + [project] + requires-python = "==3.12" + description = """ + A magic stuff is great. + Like really. + """ + "#}, + indoc ! {r#" + [project] + description = "A magic stuff is great. Like really." + requires-python = "==3.12" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + ] + "#}, + true, + (3, 13), +)] +#[case::project_dependencies_with_double_quotes( + indoc ! {r#" + [project] + dependencies = [ + 'packaging>=20.0;python_version>"3.4"', + "appdirs" + ] + requires-python = "==3.12" + "#}, + indoc ! {r#" + [project] + requires-python = "==3.12" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + ] + dependencies = [ + "appdirs", + "packaging>=20.0; python_version>'3.4'", + ] + "#}, + true, + (3, 13), +)] +#[case::project_platform_dependencies( + indoc ! {r#" + [project] + dependencies = [ + 'pyperclip; platform_system == "Darwin"', + 'pyperclip; platform_system == "Windows"', + "appdirs" + ] + requires-python = "==3.12" + "#}, + indoc ! {r#" + [project] + requires-python = "==3.12" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + ] + dependencies = [ + "appdirs", + "pyperclip; platform_system=='Darwin'", + "pyperclip; platform_system=='Windows'", + ] + "#}, + true, + (3, 13), +)] +#[case::project_opt_inline_dependencies( + indoc ! {r#" + [project] + dependencies = ["packaging>=24"] + optional-dependencies.test = ["pytest>=8.1.1", "covdefaults>=2.3"] + optional-dependencies.docs = ["sphinx-argparse-cli>=1.15", "Sphinx>=7.3.7"] + requires-python = "==3.12" + "#}, + indoc ! {r#" + [project] + requires-python = "==3.12" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + ] + dependencies = [ + "packaging>=24", + ] + optional-dependencies.docs = [ + "sphinx>=7.3.7", + "sphinx-argparse-cli>=1.15", + ] + optional-dependencies.test = [ + "covdefaults>=2.3", + "pytest>=8.1.1", + ] + "#}, + true, + (3, 13), +)] +#[case::project_opt_dependencies( + indoc ! {r#" + [project.optional-dependencies] + test = ["pytest>=8.1.1", "covdefaults>=2.3"] + docs = ["sphinx-argparse-cli>=1.15", "Sphinx>=7.3.7"] + "#}, + indoc ! {r#" + [project] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + optional-dependencies.docs = [ + "sphinx>=7.3.7", + "sphinx-argparse-cli>=1.15", + ] + optional-dependencies.test = [ + "covdefaults>=2.3", + "pytest>=8.1.1", + ] + "#}, + true, + (3, 9), +)] +#[case::project_scripts_collapse( + indoc ! {r#" + [project.scripts] + c = 'd' + a = "b" + "#}, + indoc ! {r#" + [project] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + scripts.a = "b" + scripts.c = "d" + "#}, + true, + (3, 9), +)] +#[case::project_entry_points_collapse( + indoc ! {r#" + [project] + entry-points.tox = {"tox-uv" = "tox_uv.plugin", "tox" = "tox.plugin"} + [project.scripts] + virtualenv = "virtualenv.__main__:run_with_catch" + [project.gui-scripts] + hello-world = "timmins:hello_world" + [project.entry-points."virtualenv.activate"] + bash = "virtualenv.activation.bash:BashActivator" + [project.entry-points] + B = {base = "vehicle_crash_prevention.main:VehicleBase"} + [project.entry-points."no_crashes.vehicle"] + base = "vehicle_crash_prevention.main:VehicleBase" + [project.entry-points.plugin-namespace] + plugin-name1 = "pkg.subpkg1" + plugin-name2 = "pkg.subpkg2:func" + "#}, + indoc ! {r#" + [project] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + scripts.virtualenv = "virtualenv.__main__:run_with_catch" + gui-scripts.hello-world = "timmins:hello_world" + entry-points.B.base = "vehicle_crash_prevention.main:VehicleBase" + entry-points."no_crashes.vehicle".base = "vehicle_crash_prevention.main:VehicleBase" + entry-points.plugin-namespace.plugin-name1 = "pkg.subpkg1" + entry-points.plugin-namespace.plugin-name2 = "pkg.subpkg2:func" + entry-points.tox.tox = "tox.plugin" + entry-points.tox.tox-uv = "tox_uv.plugin" + entry-points."virtualenv.activate".bash = "virtualenv.activation.bash:BashActivator" + "#}, + true, + (3, 9), +)] +#[case::project_preserve_implementation_classifiers( + indoc ! {r#" + [project] + requires-python = ">=3.8" + classifiers = [ + "License :: OSI Approved :: MIT License", + "Topic :: Software Development :: Libraries :: Python Modules", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + ] + "#}, + indoc ! {r#" + [project] + requires-python = ">=3.8" + classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Libraries :: Python Modules", + ] + "#}, + true, + (3, 10), +)] +fn test_format_project( + #[case] start: &str, + #[case] expected: &str, + #[case] keep_full_version: bool, + #[case] max_supported_python: (u8, u8), +) { + assert_eq!(evaluate(start, keep_full_version, max_supported_python), expected); +} diff --git a/pyproject-fmt/rust/src/tests/ruff_tests.rs b/pyproject-fmt/rust/src/tests/ruff_tests.rs new file mode 100644 index 0000000..48e3587 --- /dev/null +++ b/pyproject-fmt/rust/src/tests/ruff_tests.rs @@ -0,0 +1,51 @@ +use std::fs::read_to_string; +use std::path::{Path, PathBuf}; + +use common::taplo::formatter::{format_syntax, Options}; +use common::taplo::parser::parse; +use common::taplo::syntax::SyntaxElement; +use rstest::{fixture, rstest}; + +use crate::ruff::fix; +use common::table::Tables; + +fn evaluate(start: &str) -> String { + let root_ast = parse(start).into_syntax().clone_for_update(); + let count = root_ast.children_with_tokens().count(); + let mut tables = Tables::from_ast(&root_ast); + fix(&mut tables); + let entries = tables + .table_set + .iter() + .flat_map(|e| e.borrow().clone()) + .collect::>(); + root_ast.splice_children(0..count, entries); + let opt = Options { + column_width: 1, + ..Options::default() + }; + format_syntax(root_ast, opt) +} +#[fixture] +fn data() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("rust") + .join("src") + .join("data") +} + +#[rstest] +fn test_order_ruff(data: PathBuf) { + let start = read_to_string(data.join("ruff-order.start.toml")).unwrap(); + let got = evaluate(start.as_str()); + let expected = read_to_string(data.join("ruff-order.expected.toml")).unwrap(); + assert_eq!(got, expected); +} + +#[rstest] +fn test_ruff_comment_21(data: PathBuf) { + let start = read_to_string(data.join("ruff-21.start.toml")).unwrap(); + let got = evaluate(start.as_str()); + let expected = read_to_string(data.join("ruff-21.expected.toml")).unwrap(); + assert_eq!(got, expected); +} diff --git a/pyproject-fmt/src/pyproject_fmt/__init__.py b/pyproject-fmt/src/pyproject_fmt/__init__.py new file mode 100644 index 0000000..578a4cf --- /dev/null +++ b/pyproject-fmt/src/pyproject_fmt/__init__.py @@ -0,0 +1,9 @@ +"""Format pyproject.toml files.""" + +from __future__ import annotations + +from .__main__ import run + +__all__ = [ + "run", +] diff --git a/pyproject-fmt/src/pyproject_fmt/__main__.py b/pyproject-fmt/src/pyproject_fmt/__main__.py new file mode 100644 index 0000000..85a3046 --- /dev/null +++ b/pyproject-fmt/src/pyproject_fmt/__main__.py @@ -0,0 +1,79 @@ +"""Main entry point for the formatter.""" + +from __future__ import annotations + +import difflib +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from pyproject_fmt._lib import format_toml +from pyproject_fmt.cli import cli_args + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from pyproject_fmt.cli import Config + +GREEN = "\u001b[32m" +RED = "\u001b[31m" +RESET = "\u001b[0m" + + +def color_diff(diff: Iterable[str]) -> Iterable[str]: + """ + Visualize difference with colors. + + :param diff: the diff lines + """ + for line in diff: + if line.startswith("+"): + yield f"{GREEN}{line}{RESET}" + elif line.startswith("-"): + yield f"{RED}{line}{RESET}" + else: + yield line + + +def _handle_one(config: Config) -> bool: + formatted = format_toml(config.toml, config.settings) + before = config.toml + changed = before != formatted + if config.pyproject_toml is None or config.stdout: # when reading from stdin or writing to stdout, print new format + print(formatted, end="") # noqa: T201 + return changed + + if before != formatted and not config.check: + config.pyproject_toml.write_text(formatted, encoding="utf-8") + if config.no_print_diff: + return changed + try: + name = str(config.pyproject_toml.relative_to(Path.cwd())) + except ValueError: + name = str(config.pyproject_toml) + diff: Iterable[str] = [] + if changed: + diff = difflib.unified_diff(before.splitlines(), formatted.splitlines(), fromfile=name, tofile=name) + + if diff: + diff = color_diff(diff) + print("\n".join(diff)) # print diff on change # noqa: T201 + else: + print(f"no change for {name}") # noqa: T201 + return changed + + +def run(args: Sequence[str] | None = None) -> int: + """ + Run the formatter. + + :param args: command line arguments, by default use sys.argv[1:] + :return: exit code - 0 means already formatted correctly, otherwise 1 + """ + configs = cli_args(sys.argv[1:] if args is None else args) + results = [_handle_one(config) for config in configs] + return 1 if any(results) else 0 # exit with non success on change + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/pyproject-fmt/src/pyproject_fmt/_lib.pyi b/pyproject-fmt/src/pyproject_fmt/_lib.pyi new file mode 100644 index 0000000..d682a6b --- /dev/null +++ b/pyproject-fmt/src/pyproject_fmt/_lib.pyi @@ -0,0 +1,22 @@ +class Settings: + def __init__( + self, + *, + column_width: int, + indent: int, + keep_full_version: bool, + max_supported_python: tuple[int, int], + min_supported_python: tuple[int, int], + ) -> None: ... + @property + def column_width(self) -> int: ... + @property + def indent(self) -> int: ... + @property + def keep_full_version(self) -> bool: ... + @property + def max_supported_python(self) -> tuple[int, int]: ... + @property + def min_supported_python(self) -> tuple[int, int]: ... + +def format_toml(content: str, settings: Settings) -> str: ... diff --git a/pyproject-fmt/src/pyproject_fmt/cli.py b/pyproject-fmt/src/pyproject_fmt/cli.py new file mode 100644 index 0000000..d8e3521 --- /dev/null +++ b/pyproject-fmt/src/pyproject_fmt/cli.py @@ -0,0 +1,208 @@ +"""CLI interface parser.""" + +from __future__ import annotations + +import os +import sys +from argparse import ( + ArgumentDefaultsHelpFormatter, + ArgumentParser, + ArgumentTypeError, + Namespace, +) +from dataclasses import dataclass +from importlib.metadata import version +from pathlib import Path +from typing import TYPE_CHECKING + +from ._lib import Settings + +if TYPE_CHECKING: + from collections.abc import Sequence + +if sys.version_info >= (3, 11): # pragma: >=3.11 cover + import tomllib +else: # pragma: <3.11 cover + import tomli as tomllib + + +class PyProjectFmtNamespace(Namespace): + """Options for pyproject-fmt tool.""" + + inputs: list[Path] + stdout: bool + check: bool + no_print_diff: bool + + column_width: int + indent: int + keep_full_version: bool + max_supported_python: tuple[int, int] + + +@dataclass(frozen=True) +class Config: + """Configuration flags for the formatting.""" + + pyproject_toml: Path | None # path to the toml file or None if stdin + toml: str # the toml file content + stdout: bool # push to standard out, implied if reading from stdin + check: bool # check only + no_print_diff: bool # don't print diff + settings: Settings + + +def pyproject_toml_path_creator(argument: str) -> Path | None: + """ + Validate that pyproject.toml can be formatted. + + :param argument: the string argument passed in + :return: the pyproject.toml path or None if stdin + :raises ArgumentTypeError: invalid argument + """ + if argument == "-": + return None # stdin, no further validation needed + path = Path(argument).absolute() + if path.is_dir(): + path /= "pyproject.toml" + if not path.exists(): + msg = "path does not exist" + raise ArgumentTypeError(msg) + if not path.is_file(): + msg = "path is not a file" + raise ArgumentTypeError(msg) + if not os.access(path, os.R_OK): + msg = "cannot read path" + raise ArgumentTypeError(msg) + if not os.access(path, os.W_OK): + msg = "cannot write path" + raise ArgumentTypeError(msg) + return path + + +def _version_argument(got: str) -> tuple[int, int]: + parts = got.split(".") + if len(parts) != 2: # noqa: PLR2004 + msg = f"invalid version: {got}, must be e.g. 3.13" + raise ArgumentTypeError(msg) + try: + return int(parts[0]), int(parts[1]) + except ValueError as exc: + msg = f"invalid version: {got} due {exc!r}, must be e.g. 3.13" + raise ArgumentTypeError(msg) from exc + + +def _build_cli() -> ArgumentParser: + parser = ArgumentParser( + formatter_class=ArgumentDefaultsHelpFormatter, + prog="pyproject-fmt", + ) + parser.add_argument( + "-V", + "--version", + action="version", + help="print package version of pyproject_fmt", + version=f"%(prog)s ({version('pyproject-fmt')})", + ) + + mode_group = parser.add_argument_group("run mode") + mode = mode_group.add_mutually_exclusive_group() + msg = "print the formatted TOML to the stdout, implied if reading from stdin" + mode.add_argument("-s", "--stdout", action="store_true", help=msg) + msg = "check and fail if any input would be formatted, printing any diffs" + mode.add_argument("--check", action="store_true", help=msg) + mode_group.add_argument( + "-n", + "--no-print-diff", + action="store_true", + help="Flag indicating to print diff for the check mode", + ) + + format_group = parser.add_argument_group("formatting behavior") + format_group.add_argument( + "--column-width", + type=int, + default=120, + help="max column width in the TOML file", + metavar="count", + ) + format_group.add_argument( + "--indent", + type=int, + default=2, + help="number of spaces to use for indentation", + metavar="count", + ) + msg = "keep full dependency versions - do not remove redundant .0 from versions" + format_group.add_argument("--keep-full-version", action="store_true", help=msg) + format_group.add_argument( + "--max-supported-python", + metavar="minor.major", + type=_version_argument, + default=(3, 13), + help="latest Python version the project supports (e.g. 3.13)", + ) + + msg = "pyproject.toml file(s) to format, use '-' to read from stdin" + parser.add_argument( + "inputs", + nargs="+", + type=pyproject_toml_path_creator, + help=msg, + ) + return parser + + +def cli_args(args: Sequence[str]) -> list[Config]: + """ + Load the tools options. + + :param args: CLI arguments + :return: the parsed options + """ + parser = _build_cli() + opt = PyProjectFmtNamespace() + parser.parse_args(namespace=opt, args=args) + res = [] + for pyproject_toml in opt.inputs: + column_width = opt.column_width + indent = opt.indent + keep_full_version = opt.keep_full_version + max_supported_python = opt.max_supported_python + raw_pyproject_toml = sys.stdin.read() if pyproject_toml is None else pyproject_toml.read_text(encoding="utf-8") + config = tomllib.loads(raw_pyproject_toml) + if "tool" in config and "pyproject-fmt" in config["tool"]: + for key, entry in config["tool"]["pyproject-fmt"].items(): + if key == "column_width": + column_width = int(entry) + elif key == "indent": + indent = int(entry) + elif key == "keep_full_version": + keep_full_version = bool(entry) + elif key == "max_supported_python": + max_supported_python = _version_argument(entry) + res.append( + Config( + pyproject_toml=pyproject_toml, + toml=raw_pyproject_toml, + stdout=opt.stdout, + check=opt.check, + no_print_diff=opt.no_print_diff, + settings=Settings( + column_width=column_width, + indent=indent, + keep_full_version=keep_full_version, + max_supported_python=max_supported_python, + min_supported_python=(3, 9), # default for when the user did not specify via requires-python + ), + ) + ) + + return res + + +__all__ = [ + "Config", + "PyProjectFmtNamespace", + "cli_args", +] diff --git a/pyproject-fmt/src/pyproject_fmt/py.typed b/pyproject-fmt/src/pyproject_fmt/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject-fmt/tests/test_cli.py b/pyproject-fmt/tests/test_cli.py new file mode 100644 index 0000000..bbc3fa4 --- /dev/null +++ b/pyproject-fmt/tests/test_cli.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import io +import os +import sys +from importlib.metadata import version +from stat import S_IREAD, S_IWRITE +from typing import TYPE_CHECKING + +import pytest + +from pyproject_fmt.cli import cli_args + +if TYPE_CHECKING: + from pathlib import Path + + from pytest_mock import MockerFixture + + +def test_cli_version(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as context: + cli_args(["--version"]) + assert context.value.code == 0 + out, _err = capsys.readouterr() + assert out == f"pyproject-fmt ({version('pyproject-fmt')})\n" + + +def test_cli_invalid_version(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: + path = tmp_path / "pyproject.toml" + path.write_text("") + with pytest.raises(SystemExit) as context: + cli_args([str(path), "--max-supported-python", "3"]) + assert context.value.code == 2 + out, err = capsys.readouterr() + assert not out + assert "error: argument --max-supported-python: invalid version: 3, must be e.g. 3.13\n" in err + + +def test_cli_invalid_version_value(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: + path = tmp_path / "pyproject.toml" + path.write_text("") + with pytest.raises(SystemExit) as context: + cli_args([str(path), "--max-supported-python", "a.1"]) + assert context.value.code == 2 + out, err = capsys.readouterr() + assert not out + assert ( + "error: argument --max-supported-python: invalid version: a.1 due " + 'ValueError("invalid literal for int() with base 10:' + ) in err + + +def test_cli_pyproject_toml_ok(tmp_path: Path) -> None: + path = tmp_path / "tox.ini" + path.write_text("") + result = cli_args([str(path)]) + assert len(result) == 1 + assert result[0] + + +def test_cli_inputs_ok(tmp_path: Path) -> None: + paths = [] + for filename in ("tox.ini", "tox2.ini", "tox3.ini"): + path = tmp_path / filename + path.write_text("") + paths.append(path) + result = cli_args([*map(str, paths)]) + assert len(result) == 3 + + +def test_cli_pyproject_toml_stdin(mocker: MockerFixture) -> None: + mocker.patch("pyproject_fmt.cli.sys.stdin", io.StringIO("")) + result = cli_args(["-"]) + assert len(result) == 1 + assert result[0].pyproject_toml is None + assert not result[0].toml + + +def test_cli_pyproject_toml_not_exists( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as context: + cli_args([str(tmp_path / "tox.ini")]) + assert context.value.code != 0 + out, err = capsys.readouterr() + assert not out + assert "argument inputs: path does not exist" in err + + +def test_cli_pyproject_toml_not_file( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + path = tmp_path / "temp" + os.mkfifo(path) + with pytest.raises(SystemExit) as context: + cli_args([str(path)]) + assert context.value.code != 0 + out, err = capsys.readouterr() + assert not out + assert "argument inputs: path is not a file" in err + + +@pytest.mark.parametrize(("flag", "error"), [(S_IREAD, "write"), (S_IWRITE, "read")]) +@pytest.mark.skipif( + sys.platform == "win32", + reason="On Windows files cannot be read only, only folders", +) +def test_cli_pyproject_toml_permission_fail( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + flag: int, + error: str, +) -> None: + path = tmp_path / "tox.ini" + path.write_text("") + path.chmod(flag) + try: + with pytest.raises(SystemExit) as context: + cli_args([str(path)]) + finally: + path.chmod(S_IWRITE | S_IREAD) + assert context.value.code != 0 + out, err = capsys.readouterr() + assert not out + assert f"argument inputs: cannot {error} path" in err + + +def test_pyproject_toml_resolved( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + path = tmp_path / "tox.ini" + path.write_text("") + result = cli_args(["tox.ini"]) + assert len(result) == 1 + + +def test_pyproject_toml_dir(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("") + cli_args([str(tmp_path)]) diff --git a/pyproject-fmt/tests/test_lib.py b/pyproject-fmt/tests/test_lib.py new file mode 100644 index 0000000..6bf7b74 --- /dev/null +++ b/pyproject-fmt/tests/test_lib.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from textwrap import dedent + +import pytest + +from pyproject_fmt._lib import Settings, format_toml + + +@pytest.mark.parametrize( + ("start", "expected"), + [ + pytest.param( + """ + [project] + keywords = [ + "A", + ] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + ] + dynamic = [ + "B", + ] + dependencies = [ + "requests>=2.0", + ] + """, + """\ + [project] + keywords = [ + "A", + ] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + ] + dynamic = [ + "B", + ] + dependencies = [ + "requests>=2.0", + ] + """, + id="expanded", + ), + pytest.param( + """ + [project] + keywords = ["A"] + classifiers = ["Programming Language :: Python :: 3 :: Only"] + dynamic = ["B"] + dependencies = ["requests>=2.0"] + """, + """\ + [project] + keywords = [ "A" ] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + ] + dynamic = [ "B" ] + dependencies = [ "requests>=2.0" ] + """, + id="collapsed", + ), + ], +) +def test_format_toml(start: str, expected: str) -> None: + settings = Settings( + column_width=120, + indent=4, + keep_full_version=True, + min_supported_python=(3, 7), + max_supported_python=(3, 8), + ) + res = format_toml(dedent(start), settings) + assert res == dedent(expected) diff --git a/pyproject-fmt/tests/test_main.py b/pyproject-fmt/tests/test_main.py new file mode 100644 index 0000000..d94c07f --- /dev/null +++ b/pyproject-fmt/tests/test_main.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import difflib +from textwrap import dedent +from typing import TYPE_CHECKING, Any + +import pytest + +from pyproject_fmt.__main__ import GREEN, RED, RESET, color_diff, run + +if TYPE_CHECKING: + from pathlib import Path + + from pytest_mock import MockerFixture + + +def test_color_diff() -> None: + # Arrange + before = """ + abc + def + ghi +""" + after = """ + abc + abc + def +""" + diff = difflib.unified_diff(before.splitlines(), after.splitlines()) + expected_lines = f""" +{RED}--- +{RESET} +{GREEN}+++ +{RESET} +@@ -1,4 +1,4 @@ + + + abc +{GREEN}+ abc{RESET} + def +{RED}- ghi{RESET} +""".strip().splitlines() + + # Act + found_diff = color_diff(diff) + + # Assert + output_lines = [line.rstrip() for line in "\n".join(found_diff).splitlines()] + assert output_lines == expected_lines + + +def no_color(diff: Any) -> Any: + return diff + + +@pytest.mark.parametrize( + "in_place", + [ + True, + False, + ], +) +@pytest.mark.parametrize( + "check", + [ + True, + False, + ], +) +@pytest.mark.parametrize( + "cwd", + [ + True, + False, + ], +) +@pytest.mark.parametrize( + ("start", "outcome", "output"), + [ + ( + '[build-system]\nrequires = [\n "hatchling>=0.14",\n]\n', + '[build-system]\nrequires = [\n "hatchling>=0.14",\n]\n', + "no change for {0}\n", + ), + ( + '[build-system]\nrequires = ["hatchling>=0.14.0"]', + '[build-system]\nrequires = [ "hatchling>=0.14" ]\n', + "--- {0}\n\n+++ {0}\n\n@@ -1,2 +1,2 @@\n\n [build-system]\n-requires = " + '["hatchling>=0.14.0"]\n+requires = [ "hatchling>=0.14" ]\n', + ), + ], +) +def test_main( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + in_place: bool, + start: str, + outcome: str, + output: str, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + cwd: bool, + check: bool, +) -> None: + mocker.patch("pyproject_fmt.__main__.color_diff", no_color) + if cwd: + monkeypatch.chdir(tmp_path) + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text(start) + args = [str(pyproject_toml)] + if not in_place: + args.append("--stdout") + + if check: + args.append("--check") + + if not in_place: + with pytest.raises(SystemExit): + run(args) + assert pyproject_toml.read_text() == start + return + + result = run(args) + assert result == (0 if start == outcome else 1) + + out, err = capsys.readouterr() + assert not err + + if check: + assert pyproject_toml.read_text() == start + elif in_place: + name = "pyproject.toml" if cwd else str(tmp_path / "pyproject.toml") + output = output.format(name) + assert pyproject_toml.read_text() == outcome + assert out == output + else: + assert out == outcome + + +@pytest.mark.parametrize("indent", [0, 2, 4]) +def test_indent(tmp_path: Path, indent: int) -> None: + start = """\ + [build-system] + requires = [ + "A", + ] + """ + + expected = f"""\ + [build-system] + requires = [ + {" " * indent}"a", + ] + """ + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text(dedent(start)) + args = [str(pyproject_toml), "--indent", str(indent)] + run(args) + output = pyproject_toml.read_text() + assert output == dedent(expected) + + +def test_keep_full_version_cli(tmp_path: Path) -> None: + start = """\ + [build-system] + requires = [ + "a==1.0.0", + ] + + [project] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + ] + dependencies = [ + "a==1.0.0", + ] + optional-dependencies.docs = [ + "b==2.0.0", + ] + """ + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text(dedent(start)) + args = [str(pyproject_toml), "--keep-full-version", "--max-supported-python", "3.9"] + run(args) + output = pyproject_toml.read_text() + assert output == dedent(start) + + +def test_pyproject_toml_config(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + txt = """ + [project] + keywords = [ + "A", + ] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + ] + dynamic = [ + "B", + ] + dependencies = [ + "requests>=2.0", + ] + + [tool.pyproject-fmt] + column_width = 20 + indent = 4 + keep_full_version = true + max_supported_python = "3.11" + ignore_extra = true + """ + filename = tmp_path / "pyproject.toml" + filename.write_text(dedent(txt)) + run([str(filename)]) + + expected = """\ + [project] + keywords = [ + "A", + ] + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ] + dynamic = [ + "B", + ] + dependencies = [ + "requests>=2.0", + ] + + [tool.pyproject-fmt] + column_width = 20 + indent = 4 + keep_full_version = true + max_supported_python = "3.11" + ignore_extra = true + """ + got = filename.read_text() + assert got == dedent(expected) + out, err = capsys.readouterr() + assert out + assert not err + + +def test_pyproject_ftm_api_changed(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + txt = """ + [project] + requires-python = "==3.12" + """ + filename = tmp_path / "pyproject.toml" + filename.write_text(dedent(txt)) + res = run([str(filename), "--no-print-diff", "--column-width", "20"]) + + assert res == 1 + + got = filename.read_text() + expected = """\ + [project] + requires-python = "==3.12" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + ] + """ + assert got == dedent(expected) + + out, err = capsys.readouterr() + assert not out + assert not err + + +def test_pyproject_ftm_api_no_change(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + txt = """\ + [project] + requires-python = "==3.12" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + ] + """ + filename = tmp_path / "pyproject.toml" + filename.write_text(dedent(txt)) + res = run([str(filename), "--no-print-diff"]) + + assert res == 0 + + got = filename.read_text() + + assert got == dedent(txt) + + out, err = capsys.readouterr() + assert not out + assert not err diff --git a/pyproject-fmt/tests/test_pyproject_toml_fmt.py b/pyproject-fmt/tests/test_pyproject_toml_fmt.py new file mode 100644 index 0000000..a731ebe --- /dev/null +++ b/pyproject-fmt/tests/test_pyproject_toml_fmt.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import subprocess # noqa: S404 +import sys +from pathlib import Path + + +def test_help_invocation_as_module() -> None: + subprocess.check_call([sys.executable, "-m", "pyproject_fmt", "--help"]) + + +def test_help_invocation_as_script() -> None: + subprocess.check_call( + [str(Path(sys.executable).parent / "pyproject-fmt"), "--help"], + ) diff --git a/pyproject-fmt/tox.toml b/pyproject-fmt/tox.toml new file mode 100644 index 0000000..92eb6aa --- /dev/null +++ b/pyproject-fmt/tox.toml @@ -0,0 +1,109 @@ +requires = ["tox>=4.22"] +env_list = ["fix", "3.13", "3.12", "3.11", "3.10", "3.9", "type", "docs", "pkg_meta"] +skip_missing_interpreters = true + +[env_run_base] +description = "run the tests with pytest under {env_name}" +package = "wheel" +wheel_build_env = ".pkg" +dependency_groups = ["test"] +pass_env = ["PYTEST_*", "SSL_CERT_FILE"] +set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{work_dir}{/}.coverage.{env_name}" } +set_env.COVERAGE_FILECOVERAGE_PROCESS_START = "{tox_root}{/}pyproject.toml" +commands = [ + [ + "pytest", + { replace = "posargs", extend = true, default = [ + "--durations", + "5", + "--junitxml", + "{work_dir}{/}junit.{env_name}.xml", + "--no-cov-on-fail", + "--cov", + "{env_site_packages_dir}{/}pyproject_fmt", + "--cov", + "{tox_root}{/}tests", + "--cov-config", + "{tox_root}{/}pyproject.toml", + "--cov-context", + "test", + "--cov-report", + "term-missing:skip-covered", + "--cov-report", + "html:{env_tmp_dir}{/}htmlcov", + "--cov-report", + "xml:{work_dir}{/}coverage.{env_name}.xml", + "tests", + ] }, + ], +] + +[env.fix] +description = "format the code base to adhere to our styles, and complain about what we cannot do automatically" +skip_install = true +dependency_groups = ["fix"] +pass_env = [{ replace = "ref", of = ["env_run_base", "pass_env"], extend = true }, "PROGRAMDATA"] +commands = [["pre-commit", "run", "--all-files", "--show-diff-on-failure", { replace = "posargs", extend = true }]] + +[env.type] +description = "run type check on code base" +dependency_groups = ["type"] +commands = [["mypy", "src{/}pyproject_fmt"], ["mypy", "tests"]] + +[env.docs] +description = "build documentation" +dependency_groups = ["docs"] +commands = [ + [ + "sphinx-build", + "-d", + "{env_tmp_dir}{/}docs_tree", + "docs", + "{env:READTHEDOCS_OUTPUT:{work_dir}{/}docs_out}/html", + "--color", + "-b", + "html", + { replace = "posargs", default = [ + "-b", + "linkcheck", + ], extend = true }, + "-W", + ], + [ + "python", + "-c", + 'print(r"documentation available under file://{work_dir}{/}docs_out{/}index.html")', + ], +] + +[env.pkg_meta] +description = "check that the long description is valid" +skip_install = true +dependency_groups = ["pkg_meta"] +commands = [ + [ + "uv", + "build", + "--sdist", + "--wheel", + "--out-dir", + "{env_tmp_dir}", + ".", + ], + [ + "twine", + "check", + "{env_tmp_dir}{/}*", + ], + [ + "check-wheel-contents", + "--no-config", + "{env_tmp_dir}", + ], +] + +[env.dev] +description = "dev environment with all deps at {envdir}" +package = "editable" +dependency_groups = ["dev"] +commands = [["uv", "pip", "tree"], ["python", "-c", 'print(r"{env_python}")']] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f06b6d1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[tool.ruff] +line-length = 120 +format.preview = true +format.docstring-code-line-length = 100 +format.docstring-code-format = true +lint.select = [ + "ALL", +] +lint.ignore = [ + "ANN101", # no type annotation for self + "ANN401", # allow Any as type annotation + "COM812", # Conflict with formatter + "CPY", # No copyright statements + "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible + "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible + "DOC", # no good support + "ISC001", # Conflict with formatter + "S104", # Possible binding to all interface +] +lint.per-file-ignores."**/tests/**/*.py" = [ + "D", # don't care about documentation in tests + "FBT", # don't care about booleans as positional arguments in tests + "INP001", # no implicit namespace + "PLC2701", # private import + "PLR0913", # any number of arguments in tests + "PLR0917", # any number of arguments in tests + "PLR2004", # Magic value used in comparison, consider replacing with a constant variable + "S101", # asserts allowed in tests... + "S603", # `subprocess` call: check for execution of untrusted input +] +lint.per-file-ignores."tasks/**.py" = [ + "D", # don't care about documentation in tests + "INP001", # is not a namespace + "T201", # allow print +] +lint.isort = { known-first-party = [ + "pyproject_fmt", +], required-imports = [ + "from __future__ import annotations", +] } +lint.preview = true + +[tool.codespell] +builtin = "clear,usage,en-GB_to_en-US" +count = true +ignore-words = "ignore-words.txt" +write-changes = true diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..4cef0b7 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.81" diff --git a/tasks/changelog.py b/tasks/changelog.py new file mode 100644 index 0000000..a96bd60 --- /dev/null +++ b/tasks/changelog.py @@ -0,0 +1,116 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "gitpython>=3.1.43", +# "pygithub>=2.4", +# ] +# /// +"""Generate the changelog on release.""" + +from __future__ import annotations + +import os +import re +from argparse import ArgumentParser, Namespace +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Iterator + +from git import Repo +from github import Github, Repository +from github.Auth import Token +from tomllib import load + +if TYPE_CHECKING: + from github.Repository import Repository as GitHubRepository + +ROOT = Path(__file__).parents[1] + + +class Options(Namespace): + project: str + pr: int | None + base: str + + +def run() -> None: + options = parse_cli() + print(options) + project = ROOT / options.project + changelog_file = project / "CHANGELOG.md" + + version = get_version(project) + changelog = changelog_file.read_text(encoding="utf-8") + anchor = f'' + + logs = [] + git_repo = Repo(ROOT) + github = Github(auth=Token(os.environ["GITHUB_TOKEN"])) + at = "tox-dev/toml-fmt" + gh_repo = github.get_repo(at) + for title, pr, by in entries(gh_repo, git_repo, options.pr, options.base): + suffix = f" in [#${pr}](https://github.com/{at}/pull/{pr})]" if pr else "" + logs.append(f"{title} by [@{by}](https://github.com/{by}){suffix}") + + if logs: + new_lines = [ + anchor, + f"## {version} - {datetime.now(tz=UTC).date().isoformat()}", + "", + *[f" - {i}" for i in logs], + "", + "", + ] + new = "\n".join(new_lines) + print(new) + changelog_file.write_text(new + changelog) + else: + new = "" + + if output := os.environ.get("GITHUB_TOKEN"): + with Path(output).open("at+", encoding="utf-8") as file_handler: + file_handler.write(f"version={version}\n") + file_handler.write(f"changelog< Options: + parser = ArgumentParser() + parser.add_argument("project", choices=["pyproject-fmt"]) + parser.add_argument("pr", type=lambda s: int(s) if s else None) + parser.add_argument("base", type=str) + options = Options() + parser.parse_args(namespace=options) + return options + + +def get_version(base: Path) -> str: + with (base / "Cargo.toml").open("rb") as cargo_toml_file_handler: + return load(cargo_toml_file_handler)["package"]["version"] + + +def entries( + gh_repo: GitHubRepository, git_repo: Repository, pr: int | None, base: str | None +) -> Iterator[tuple[str, str, str]]: + if pr: + pull = gh_repo.get_pull(pr) + yield pull.title, str(pr), pull.user.login + tags = {tag.commit.hexsha for tag in git_repo.tags} + pr_re = re.compile(r"(?P.*)[(]#(?P<pr>\d+)[)]") + found_base = not base + for change in git_repo.iter_commits(): + if change.hexsha in tags: + break + found_base = found_base or change.hexsha == base + if not found_base or change.author.name in {"pre-commit-ci[bot]", "dependabot[bot]"}: + continue + title = change.message.split("\n")[0].strip() + by = gh_repo.get_commit(change.hexsha).author.login + if match := pr_re.match(title): + group = match.groupdict() + yield group["title"].strip(), group["pr"], by + else: + yield title, "", by + + +if __name__ == "__main__": + run()