├── .editorconfig ├── .github ├── FUNDING.yml ├── renovate.json5 ├── workflows │ ├── ci.yml │ ├── release.yml │ ├── validate-ci-workflows.yml │ └── validate-renovate-config.yml └── zizmor.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── docs ├── CHANGELOG.md ├── CONTRIBUTING.md ├── index.md ├── supported-package-managers.md └── usage-and-configuration.md ├── mkdocs.yml ├── pyproject.toml ├── rust-toolchain.toml ├── scripts └── bump-version.sh ├── src ├── bin │ └── migrate-to-uv.rs ├── cli.rs ├── converters │ ├── mod.rs │ ├── pip │ │ ├── dependencies.rs │ │ └── mod.rs │ ├── pipenv │ │ ├── dependencies.rs │ │ ├── mod.rs │ │ ├── project.rs │ │ └── sources.rs │ ├── poetry │ │ ├── build_backend.rs │ │ ├── dependencies.rs │ │ ├── mod.rs │ │ ├── project.rs │ │ ├── sources.rs │ │ └── version.rs │ └── pyproject_updater.rs ├── detector.rs ├── lib.rs ├── logger.rs ├── schema │ ├── hatch.rs │ ├── mod.rs │ ├── pep_621.rs │ ├── pipenv.rs │ ├── poetry.rs │ ├── pyproject.rs │ ├── utils.rs │ └── uv.rs └── toml.rs ├── tests ├── common │ └── mod.rs ├── fixtures │ ├── pip │ │ ├── existing_project │ │ │ ├── pyproject.toml │ │ │ └── requirements.txt │ │ └── full │ │ │ ├── constraints-2.txt │ │ │ ├── constraints.txt │ │ │ ├── requirements-dev.txt │ │ │ ├── requirements-typing.txt │ │ │ └── requirements.txt │ ├── pip_tools │ │ ├── existing_project │ │ │ ├── pyproject.toml │ │ │ ├── requirements.in │ │ │ └── requirements.txt │ │ ├── full │ │ │ ├── requirements-dev.in │ │ │ ├── requirements-dev.txt │ │ │ ├── requirements-typing.in │ │ │ ├── requirements-typing.txt │ │ │ ├── requirements.in │ │ │ └── requirements.txt │ │ └── with_lock_file │ │ │ ├── requirements-dev.in │ │ │ ├── requirements-dev.txt │ │ │ ├── requirements-typing.in │ │ │ ├── requirements-typing.txt │ │ │ ├── requirements.in │ │ │ └── requirements.txt │ ├── pipenv │ │ ├── existing_project │ │ │ ├── Pipfile │ │ │ └── pyproject.toml │ │ ├── full │ │ │ ├── Pipfile │ │ │ └── pyproject.toml │ │ ├── minimal │ │ │ └── Pipfile │ │ └── with_lock_file │ │ │ ├── Pipfile │ │ │ └── Pipfile.lock │ ├── poetry │ │ ├── existing_project │ │ │ └── pyproject.toml │ │ ├── full │ │ │ └── pyproject.toml │ │ ├── minimal │ │ │ └── pyproject.toml │ │ ├── pep_621 │ │ │ └── pyproject.toml │ │ └── with_lock_file │ │ │ ├── poetry.lock │ │ │ ├── poetry.toml │ │ │ └── pyproject.toml │ └── uv │ │ ├── minimal │ │ └── pyproject.toml │ │ └── with_lock │ │ ├── pyproject.toml │ │ └── uv.lock ├── pip.rs ├── pip_tools.rs ├── pipenv.rs └── poetry.rs └── uv.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{json5,yml,yaml}] 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mkniewallner 2 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | extends: [ 4 | "github>mkniewallner/renovate-config:default.json5", 5 | ":automergePatch", 6 | ], 7 | packageRules: [ 8 | { 9 | matchPackageNames: ["uv", "astral-sh/uv-pre-commit"], 10 | groupName: "uv-version", 11 | }, 12 | ], 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 10 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} 11 | 12 | env: 13 | PYTHON_VERSION: '3.13' 14 | # renovate: datasource=pypi depName=uv 15 | UV_VERSION: '0.6.17' 16 | 17 | permissions: {} 18 | 19 | jobs: 20 | quality: 21 | runs-on: ubuntu-24.04 22 | steps: 23 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 24 | with: 25 | persist-credentials: false 26 | 27 | - name: Install Rust toolchain 28 | run: rustup component add clippy rustfmt 29 | 30 | - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 31 | 32 | - name: Run cargo fmt 33 | run: cargo fmt --all --check 34 | 35 | - name: Run clippy 36 | run: cargo clippy --all-targets --all-features -- -D warnings 37 | 38 | tests: 39 | strategy: 40 | matrix: 41 | os: 42 | - name: linux 43 | image: ubuntu-24.04 44 | - name: macos 45 | image: macos-15 46 | - name: windows 47 | image: windows-2025 48 | fail-fast: false 49 | runs-on: ${{ matrix.os.image }} 50 | name: tests (${{ matrix.os.name }}) 51 | steps: 52 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 53 | with: 54 | persist-credentials: false 55 | 56 | - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 57 | 58 | - name: Install uv 59 | uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6 60 | with: 61 | version: ${{ env.UV_VERSION }} 62 | 63 | - name: Run unit tests 64 | run: make test-unit 65 | 66 | - name: Run integration tests 67 | run: make test-integration 68 | 69 | check-docs: 70 | runs-on: ubuntu-24.04 71 | steps: 72 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 73 | with: 74 | persist-credentials: false 75 | 76 | - name: Install uv 77 | uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6 78 | with: 79 | version: ${{ env.UV_VERSION }} 80 | 81 | - name: Install Python 82 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 83 | with: 84 | python-version: ${{ env.PYTHON_VERSION }} 85 | 86 | - name: Check if documentation can be built 87 | run: uv run --only-group docs mkdocs build --strict 88 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | env: 9 | PYTHON_VERSION: '3.13' 10 | # renovate: datasource=pypi depName=uv 11 | UV_VERSION: '0.6.17' 12 | 13 | permissions: {} 14 | 15 | jobs: 16 | linux: 17 | runs-on: ubuntu-24.04 18 | strategy: 19 | matrix: 20 | target: [x86_64, aarch64] 21 | manylinux: [auto, musllinux_1_1] 22 | steps: 23 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 24 | with: 25 | persist-credentials: false 26 | 27 | - name: Build wheels 28 | uses: PyO3/maturin-action@aef21716ff3dcae8a1c301d23ec3e4446972a6e3 # v1 29 | with: 30 | target: ${{ matrix.target }} 31 | manylinux: ${{ matrix.manylinux }} 32 | args: --release --out dist 33 | sccache: 'true' 34 | 35 | - name: Upload wheels 36 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 37 | with: 38 | name: wheels-linux-${{ matrix.target }}-${{ matrix.manylinux }} 39 | path: dist 40 | 41 | windows: 42 | runs-on: windows-2025 43 | strategy: 44 | matrix: 45 | target: [x64, aarch64] 46 | steps: 47 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 48 | with: 49 | persist-credentials: false 50 | 51 | - name: Build wheels 52 | uses: PyO3/maturin-action@aef21716ff3dcae8a1c301d23ec3e4446972a6e3 # v1 53 | with: 54 | # Recent versions (last one tested 1.7.8) lead to failures on Windows aarch64, so forcing the version for now. 55 | maturin-version: '1.7.4' 56 | target: ${{ matrix.target }} 57 | args: --release --out dist 58 | sccache: 'true' 59 | 60 | - name: Upload wheels 61 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 62 | with: 63 | name: wheels-windows-${{ matrix.target }} 64 | path: dist 65 | 66 | macos: 67 | runs-on: macos-15 68 | strategy: 69 | matrix: 70 | target: [x86_64, aarch64] 71 | steps: 72 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 73 | with: 74 | persist-credentials: false 75 | 76 | - name: Build wheels 77 | uses: PyO3/maturin-action@aef21716ff3dcae8a1c301d23ec3e4446972a6e3 # v1 78 | with: 79 | target: ${{ matrix.target }} 80 | args: --release --out dist 81 | sccache: 'true' 82 | 83 | - name: Upload wheels 84 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 85 | with: 86 | name: wheels-macos-${{ matrix.target }} 87 | path: dist 88 | 89 | sdist: 90 | runs-on: ubuntu-24.04 91 | steps: 92 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 93 | with: 94 | persist-credentials: false 95 | 96 | - name: Build sdist 97 | uses: PyO3/maturin-action@aef21716ff3dcae8a1c301d23ec3e4446972a6e3 # v1 98 | with: 99 | command: sdist 100 | args: --out dist 101 | 102 | - name: Upload sdist 103 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 104 | with: 105 | name: wheels-sdist 106 | path: dist 107 | 108 | publish: 109 | name: Publish 110 | runs-on: ubuntu-24.04 111 | needs: [linux, windows, macos, sdist] 112 | environment: pypi 113 | permissions: 114 | id-token: write 115 | contents: write 116 | attestations: write 117 | if: ${{ github.event_name == 'release' }} 118 | steps: 119 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 120 | 121 | - name: Generate artifact attestation 122 | uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2 123 | with: 124 | subject-path: 'wheels-*/*' 125 | 126 | - name: Publish to PyPI 127 | uses: PyO3/maturin-action@aef21716ff3dcae8a1c301d23ec3e4446972a6e3 # v1 128 | with: 129 | command: upload 130 | args: --non-interactive --skip-existing wheels-*/* 131 | 132 | publish-docs: 133 | runs-on: ubuntu-24.04 134 | needs: publish 135 | permissions: 136 | contents: write 137 | if: ${{ github.event_name == 'release' }} 138 | steps: 139 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 140 | 141 | - name: Install uv 142 | uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6 143 | with: 144 | version: ${{ env.UV_VERSION }} 145 | 146 | - name: Install Python 147 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 148 | with: 149 | python-version: ${{ env.PYTHON_VERSION }} 150 | 151 | - name: Deploy documentation 152 | run: uv run --only-group docs mkdocs gh-deploy --force 153 | -------------------------------------------------------------------------------- /.github/workflows/validate-ci-workflows.yml: -------------------------------------------------------------------------------- 1 | name: Validate CI workflows 2 | 3 | on: 4 | pull_request: 5 | paths: [.github/workflows/*] 6 | push: 7 | branches: [main] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 11 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} 12 | 13 | env: 14 | # renovate: datasource=pypi depName=uv 15 | UV_VERSION: '0.6.17' 16 | # renovate: datasource=pypi depName=zizmor 17 | ZIZMOR_VERSION: '1.9.0' 18 | 19 | permissions: {} 20 | 21 | jobs: 22 | validate-ci-workflows: 23 | runs-on: ubuntu-24.04 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 27 | with: 28 | persist-credentials: false 29 | 30 | - name: Install uv 31 | uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6 32 | with: 33 | version: ${{ env.UV_VERSION }} 34 | 35 | - name: Run zizmor 36 | run: uvx zizmor@${ZIZMOR_VERSION} . 37 | env: 38 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/validate-renovate-config.yml: -------------------------------------------------------------------------------- 1 | name: Validate Renovate configuration 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - .github/workflows/validate-renovate-config.yml 7 | - .github/renovate.json5 8 | push: 9 | branches: [main] 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref || github.head_ref }} 13 | cancel-in-progress: true 14 | 15 | permissions: {} 16 | 17 | jobs: 18 | validate-renovate-config: 19 | runs-on: ubuntu-24.04 20 | steps: 21 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 22 | with: 23 | persist-credentials: false 24 | 25 | - run: npx -p renovate renovate-config-validator 26 | -------------------------------------------------------------------------------- /.github/zizmor.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | artipacked: 3 | ignore: 4 | # Required for publishing documentation to `gh-pages` branch. 5 | - release.yml:139 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | # mkdocs documentation 4 | /site 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: cargo-check-lock 5 | name: check cargo lock file consistency 6 | entry: cargo check 7 | args: ["--locked", "--all-targets", "--all-features"] 8 | language: system 9 | pass_filenames: false 10 | files: Cargo\.toml$ 11 | 12 | - repo: local 13 | hooks: 14 | - id: cargo-fmt 15 | name: cargo fmt 16 | entry: cargo fmt 17 | args: ["--all", "--"] 18 | language: system 19 | types: [rust] 20 | pass_filenames: false 21 | 22 | - repo: local 23 | hooks: 24 | - id: cargo-clippy 25 | name: cargo clippy 26 | entry: cargo clippy 27 | args: ["--all-targets", "--all-features", "--", "-D", "warnings"] 28 | language: system 29 | types: [rust] 30 | pass_filenames: false 31 | 32 | - repo: https://github.com/astral-sh/uv-pre-commit 33 | rev: "0.6.17" 34 | hooks: 35 | - id: uv-lock 36 | name: check uv lock file consistency 37 | args: ["--locked"] 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.7.2 - 2025-03-25 4 | 5 | ### Bug fixes 6 | 7 | * [pipenv] Handle `*` for version ([#212](https://github.com/mkniewallner/migrate-to-uv/pull/212)) 8 | 9 | ## 0.7.1 - 2025-02-22 10 | 11 | ### Bug fixes 12 | 13 | * Handle map for PEP 621 `license` field ([#156](https://github.com/mkniewallner/migrate-to-uv/pull/156)) 14 | 15 | ## 0.7.0 - 2025-02-15 16 | 17 | ### Features 18 | 19 | * Add `--skip-uv-checks` to skip checking if uv is already used in a project ([#118](https://github.com/mkniewallner/migrate-to-uv/pull/118)) 20 | 21 | ### Bug fixes 22 | 23 | * [pip/pip-tools] Warn on unhandled dependency formats ([#103](https://github.com/mkniewallner/migrate-to-uv/pull/103)) 24 | * [pip/pip-tools] Ignore inline comments when parsing dependencies ([#105](https://github.com/mkniewallner/migrate-to-uv/pull/105)) 25 | * [poetry] Migrate scripts that use `scripts = { callable = "foo:run" }` format instead of crashing ([#138](https://github.com/mkniewallner/migrate-to-uv/pull/138)) 26 | 27 | ## 0.6.0 - 2025-01-20 28 | 29 | Existing data in `[project]` section of `pyproject.toml` is now preserved by default when migrating. If you prefer that the section is fully replaced, this can be done by setting `--replace-project-section` flag, like so: 30 | 31 | ```bash 32 | migrate-to-uv --replace-project-section 33 | ``` 34 | 35 | Poetry projects that use PEP 621 syntax to define project metadata, for which support was added in [Poetry 2.0](https://python-poetry.org/blog/announcing-poetry-2.0.0/), are now supported. 36 | 37 | ### Features 38 | 39 | * Preserve existing data in `[project]` section of `pyproject.toml` when migrating ([#84](https://github.com/mkniewallner/migrate-to-uv/pull/84)) 40 | * [poetry] Support migrating projects using PEP 621 ([#85](https://github.com/mkniewallner/migrate-to-uv/pull/85)) 41 | 42 | ## 0.5.0 - 2025-01-18 43 | 44 | ### Features 45 | 46 | * [poetry] Delete `poetry.toml` after migration ([#62](https://github.com/mkniewallner/migrate-to-uv/pull/62)) 47 | * [pipenv] Delete `Pipfile.lock` after migration ([#66](https://github.com/mkniewallner/migrate-to-uv/pull/66)) 48 | * Exit if uv is detected as a package manager ([#61](https://github.com/mkniewallner/migrate-to-uv/pull/61)) 49 | 50 | ### Bug fixes 51 | 52 | * Ensure that lock file exists before parsing ([#67](https://github.com/mkniewallner/migrate-to-uv/pull/67)) 53 | 54 | ### Documentation 55 | 56 | * Explain how to set credentials for private indexes ([#60](https://github.com/mkniewallner/migrate-to-uv/pull/60)) 57 | 58 | ## 0.4.0 - 2025-01-17 59 | 60 | When generating `uv.lock` with `uv lock` command, `migrate-to-uv` now keeps the same versions dependencies were locked to with the previous package manager (if a lock file was found), both for direct and transitive dependencies. This is supported for Poetry, Pipenv, and pip-tools. 61 | 62 | This new behavior can be opted out by setting `--ignore-locked-versions` flag, like so: 63 | 64 | ```bash 65 | migrate-to-uv --ignore-locked-versions 66 | ``` 67 | 68 | ### Features 69 | 70 | * Keep locked dependencies versions when generating `uv.lock` ([#56](https://github.com/mkniewallner/migrate-to-uv/pull/56)) 71 | 72 | ## 0.3.0 - 2025-01-12 73 | 74 | Dependencies are now locked with `uv lock` at the end of the migration, if `uv` is detected as an executable. This new behavior can be opted out by setting `--skip-lock` flag, like so: 75 | 76 | ```bash 77 | migrate-to-uv --skip-lock 78 | ``` 79 | 80 | ### Features 81 | 82 | * Lock dependencies at the end of migration ([#46](https://github.com/mkniewallner/migrate-to-uv/pull/46)) 83 | 84 | ## 0.2.1 - 2025-01-05 85 | 86 | ### Bug fixes 87 | 88 | * [poetry] Avoid crashing when an extra lists a non-existing dependency ([#30](https://github.com/mkniewallner/migrate-to-uv/pull/30)) 89 | 90 | ## 0.2.0 - 2025-01-05 91 | 92 | ### Features 93 | 94 | * Support migrating projects using `pip` and `pip-tools` ([#24](https://github.com/mkniewallner/migrate-to-uv/pull/24)) 95 | * [poetry] Migrate data from `packages`, `include` and `exclude` to Hatch build backend ([#16](https://github.com/mkniewallner/migrate-to-uv/pull/16)) 96 | 97 | ## 0.1.2 - 2025-01-02 98 | 99 | ### Bug fixes 100 | 101 | * [pipenv] Correctly update `pyproject.toml` ([#19](https://github.com/mkniewallner/migrate-to-uv/pull/19)) 102 | * Do not insert `[tool.uv]` if empty ([#17](https://github.com/mkniewallner/migrate-to-uv/pull/17)) 103 | 104 | ## 0.1.1 - 2024-12-26 105 | 106 | ### Miscellaneous 107 | 108 | * Fix documentation publishing and package metadata ([#3](https://github.com/mkniewallner/migrate-to-uv/pull/3)) 109 | 110 | ## 0.1.0 - 2024-12-26 111 | 112 | Initial release, with support for Poetry and Pipenv. 113 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Setup 4 | 5 | [Rust](https://rustup.rs/) is required to build the project. 6 | 7 | ## Linting and formatting 8 | 9 | The project uses several tools from the Rust ecosystem to check for common linting issues, and ensure that the code is 10 | correctly formatted, like: 11 | 12 | - [clippy](https://doc.rust-lang.org/clippy/) for linting 13 | - [rustfmt](https://rust-lang.github.io/rustfmt/) for formatting 14 | 15 | [pre-commit](https://pre-commit.com/) is used to ensure that all tools are run at commit time. You can install hooks in 16 | the project with: 17 | 18 | ```bash 19 | pre-commit install 20 | ``` 21 | 22 | This will automatically run the relevant git hooks based on the files that are modified whenever you commit. 23 | 24 | You can also run all hooks manually without committing with: 25 | 26 | ```bash 27 | pre-commit run --all-files 28 | ``` 29 | 30 | ## Testing 31 | 32 | Both unit and integration tests are used to ensure that the code work as intended. They can be run with: 33 | 34 | ```bash 35 | make test 36 | ``` 37 | 38 | Unit tests are located in modules, alongside the code, under `src` directory, and can be run with: 39 | 40 | ```bash 41 | make test-unit 42 | ``` 43 | 44 | Integration tests are located under `tests` directory, and can be run with: 45 | 46 | ```bash 47 | make test-integration 48 | ``` 49 | 50 | As integration tests depend on [uv](https://docs.astral.sh/uv/) for performing locking, make sure that it is present on 51 | your machine before running them. 52 | 53 | ### Snapshots 54 | 55 | Both unit and integration tests use snapshot testing through [insta](https://insta.rs/), to assert things like the 56 | content of files or command line outputs. Those snapshots can either be asserted right into the code, or against files 57 | stored in `snapshots` directories, for instance: 58 | 59 | ```rust 60 | #[test] 61 | fn test_with_snapshots() { 62 | // Inline snapshot 63 | insta::assert_snapshot!(foo(), @r###" 64 | [project] 65 | name = "foo" 66 | version = "0.0.1" 67 | "###); 68 | 69 | // External snapshot, stored under `snapshots` directory 70 | insta::assert_snapshot!(foo()); 71 | } 72 | ``` 73 | 74 | In both cases, if you update code that changes the output of snapshots, you will be prompted to review the updated 75 | snapshots with: 76 | 77 | ```bash 78 | cargo insta review 79 | ``` 80 | 81 | You can then accept the changes, if they look correct according to the changed code. 82 | 83 | ## Documentation 84 | 85 | Documentation is built using [mkdocs](https://www.mkdocs.org/) 86 | and [mkdocs-material](https://squidfunk.github.io/mkdocs-material/). 87 | 88 | It can be run locally with [uv](https://docs.astral.sh/uv/) by using: 89 | 90 | ```bash 91 | make doc-serve 92 | ``` 93 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "migrate-to-uv" 3 | version = "0.7.2" 4 | edition = "2024" 5 | rust-version = "1.87" 6 | license = "MIT" 7 | authors = ["Mathieu Kniewallner "] 8 | default-run = "migrate-to-uv" 9 | 10 | [dependencies] 11 | clap = { version = "=4.5.39", features = ["derive"] } 12 | clap-verbosity-flag = "=3.0.3" 13 | env_logger = "=0.11.8" 14 | indexmap = { version = "=2.9.0", features = ["serde"] } 15 | log = "=0.4.27" 16 | owo-colors = "=4.2.1" 17 | pep440_rs = "=0.7.3" 18 | pep508_rs = "=0.9.2" 19 | regex = "=1.11.1" 20 | serde = { version = "=1.0.219", features = ["derive"] } 21 | serde_json = "=1.0.140" 22 | toml = "=0.8.22" 23 | toml_edit = { version = "=0.22.26", features = ["display", "serde"] } 24 | url = "=2.5.4" 25 | 26 | [dev-dependencies] 27 | insta = { version = "=1.43.1", features = ["filters"] } 28 | insta-cmd = "=0.6.0" 29 | rstest = "=0.25.0" 30 | tempfile = "=3.20.0" 31 | 32 | [lints.clippy] 33 | pedantic = { level = "warn", priority = -1 } 34 | too_many_lines = "allow" 35 | 36 | [profile.dev.package] 37 | insta.opt-level = 3 38 | similar.opt-level = 3 39 | 40 | [profile.release] 41 | lto = "fat" 42 | codegen-units = 1 43 | panic = "abort" 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025, Mathieu Kniewallner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test-unit 2 | test-unit: 3 | cargo test --lib 4 | 5 | .PHONY: test-integration 6 | test-integration: 7 | # Disable parallelism as this causes concurrency issues on Windows when uv cache is accessed. 8 | UV_NO_CACHE=1 cargo test --test '*' -- --test-threads 1 9 | 10 | .PHONY: test 11 | test: test-unit test-integration 12 | 13 | .PHONY: doc-serve 14 | doc-serve: 15 | uv run --only-group docs mkdocs serve 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # migrate-to-uv 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/migrate-to-uv.svg)](https://pypi.org/project/migrate-to-uv/) 4 | [![License](https://img.shields.io/pypi/l/migrate-to-uv.svg)](https://pypi.org/project/migrate-to-uv/) 5 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/migrate-to-uv.svg)](https://pypi.org/project/migrate-to-uv/) 6 | 7 | `migrate-to-uv` migrates a project to [uv](https://github.com/astral-sh/uv) from another package manager. 8 | 9 | ## Usage 10 | 11 | ```bash 12 | # With uv 13 | uvx migrate-to-uv 14 | 15 | # With pipx 16 | pipx run migrate-to-uv 17 | ``` 18 | 19 | ## Supported package managers 20 | 21 | The following package managers are supported: 22 | 23 | - [Poetry](https://python-poetry.org/) (including projects 24 | using [PEP 621 in Poetry 2.0+](https://python-poetry.org/blog/announcing-poetry-2.0.0/)) 25 | - [Pipenv](https://pipenv.pypa.io/en/stable/) 26 | - [pip-tools](https://pip-tools.readthedocs.io/en/stable/) 27 | - [pip](https://pip.pypa.io/en/stable/) 28 | 29 | More package managers (e.g., [setuptools](https://setuptools.pypa.io/en/stable/)) could be implemented in the future. 30 | 31 | ## Features 32 | 33 | `migrate-to-uv` converts most existing metadata from supported package managers when migrating to uv, including: 34 | 35 | - [Project metadata](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#writing-pyproject-toml) (`name`, `version`, `authors`, ...) 36 | - [Dependencies and optional dependencies](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#dependencies-optional-dependencies) 37 | - [Dependency groups](https://peps.python.org/pep-0735/) 38 | - [Dependency sources](https://docs.astral.sh/uv/concepts/projects/dependencies/#dependency-sources) (index, git, URL, path) 39 | - [Dependency markers](https://packaging.python.org/en/latest/specifications/dependency-specifiers/) 40 | - [Entry points](https://packaging.python.org/en/latest/specifications/pyproject-toml/#entry-points) 41 | 42 | Version definitions set for dependencies are also preserved, and converted to their 43 | equivalent [PEP 440](https://peps.python.org/pep-0440/) for package managers that use their own syntax (for instance 44 | Poetry's [caret](https://python-poetry.org/docs/dependency-specification/#caret-requirements) syntax). 45 | 46 | At the end of the migration, `migrate-to-uv` also generates `uv.lock` file with `uv lock` command to lock dependencies, 47 | and keeps dependencies (both direct and transitive) to the exact same versions they were locked to with the previous 48 | package manager, if a lock file was found. 49 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ../CHANGELOG.md -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | `migrate-to-uv` migrates a project to [uv](https://github.com/astral-sh/uv) from another package manager. 2 | 3 | Try it now: 4 | 5 | ```bash 6 | # With uv 7 | uvx migrate-to-uv 8 | 9 | # With pipx 10 | pipx run migrate-to-uv 11 | ``` 12 | 13 | The following package managers are supported: 14 | 15 | - [Poetry](supported-package-managers.md#poetry) (including projects 16 | using [PEP 621 in Poetry 2.0+](https://python-poetry.org/blog/announcing-poetry-2.0.0/)) 17 | - [Pipenv](supported-package-managers.md#pipenv) 18 | - [pip-tools](supported-package-managers.md#pip-tools) 19 | - [pip](supported-package-managers.md#pip) 20 | 21 | More package managers (e.g., [setuptools](https://setuptools.pypa.io/en/stable/)) could be implemented in the 22 | future. 23 | 24 | ## Features 25 | 26 | `migrate-to-uv` converts most existing metadata from supported package managers when migrating to uv, including: 27 | 28 | - [Project metadata](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#writing-pyproject-toml) (`name`, `version`, `authors`, ...) 29 | - [Dependencies and optional dependencies](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#dependencies-optional-dependencies) 30 | - [Dependency groups](https://peps.python.org/pep-0735/) 31 | - [Dependency sources](https://docs.astral.sh/uv/concepts/projects/dependencies/#dependency-sources) (index, git, URL, path) 32 | - [Dependency markers](https://packaging.python.org/en/latest/specifications/dependency-specifiers/) 33 | - [Entry points](https://packaging.python.org/en/latest/specifications/pyproject-toml/#entry-points) 34 | 35 | Version definitions set for dependencies are also preserved, and converted to their 36 | equivalent [PEP 440](https://peps.python.org/pep-0440/) for package managers that use their own syntax (for instance 37 | [caret](https://python-poetry.org/docs/dependency-specification/#caret-requirements) for Poetry). 38 | 39 | At the end of the migration, `migrate-to-uv` also generates `uv.lock` file with `uv lock` command to lock dependencies, 40 | and keeps dependencies (both direct and transitive) to the exact same versions they were locked to with the previous 41 | package manager, if a lock file was found. 42 | 43 | !!! warning 44 | 45 | Although `migrate-to-uv` matches current package manager definition as closely as possible when performing the migration, it is still heavily recommended to double check the end result, especially if you are migrating a package that is meant to be publicly distributed. 46 | 47 | If you notice a behaviour that does not match the previous package manager when migrating, please [raise an issue](https://github.com/mkniewallner/migrate-to-uv/issues), if not already reported. 48 | -------------------------------------------------------------------------------- /docs/supported-package-managers.md: -------------------------------------------------------------------------------- 1 | # Supported package managers 2 | 3 | `migrate-to-uv` supports multiple package managers. By default, it tries to auto-detect the package manager based on the 4 | files (and their content) used by the package managers it supports. If you need to enforce a specific package manager to 5 | be used, use [`--package-manager`](usage-and-configuration.md#-package-manager). 6 | 7 | ## Poetry 8 | 9 | !!! note 10 | 11 | `migrate-to-uv` supports migrating both projects that use Poetry-specific syntax for defining project metadata, and 12 | projects that use PEP 621, added in [Poetry 2.0](https://python-poetry.org/blog/announcing-poetry-2.0.0/). 13 | 14 | All existing [Poetry](https://python-poetry.org/) metadata should be converted to uv when performing the migration: 15 | 16 | - [Project metadata](https://python-poetry.org/docs/pyproject/) (`name`, `version`, `authors`, ...) 17 | - [Dependencies and dependency groups](https://python-poetry.org/docs/pyproject/#dependencies-and-dependency-groups) 18 | (PyPI, path, git, URL) 19 | - [Dependency extras](https://python-poetry.org/docs/pyproject/#extras) (also known as optional dependencies) 20 | - [Dependency sources](https://python-poetry.org/docs/repositories/) 21 | - [Dependency markers](https://python-poetry.org/docs/dependency-specification/#using-environment-markers) (including 22 | [`python`](https://python-poetry.org/docs/dependency-specification/#python-restricted-dependencies) and `platform`) 23 | - [Multiple constraints dependencies](https://python-poetry.org/docs/dependency-specification/#multiple-constraints-dependencies) 24 | - Package distribution metadata ([`packages`](https://python-poetry.org/docs/pyproject/#packages), [`include` and `exclude`](https://python-poetry.org/docs/pyproject/#exclude-and-include)) 25 | - [Supported Python versions](https://python-poetry.org/docs/basic-usage/#setting-a-python-version) 26 | - [Scripts](https://python-poetry.org/docs/pyproject/#scripts) and 27 | [plugins](https://python-poetry.org/docs/pyproject/#plugins) (also known as entry points) 28 | 29 | Version definitions set for dependencies are also preserved, and converted to their 30 | equivalent [PEP 440](https://peps.python.org/pep-0440/) format used by uv, even for Poetry-specific version 31 | specification (e.g., [caret](https://python-poetry.org/docs/dependency-specification/#caret-requirements) (`^`) 32 | and [tilde](https://python-poetry.org/docs/dependency-specification/#tilde-requirements) (`~`)). 33 | 34 | ### Build backend 35 | 36 | As uv does not yet have a stable build backend (see [astral-sh/uv#8779](https://github.com/astral-sh/uv/issues/8779) for more details), when 37 | performing the migration for libraries, `migrate-to-uv` sets [Hatch](https://hatch.pypa.io/latest/) as a build 38 | backend, migrating: 39 | 40 | - Poetry [`packages`](https://python-poetry.org/docs/pyproject/#packages) and [`include`](https://python-poetry.org/docs/pyproject/#exclude-and-include) to Hatch [`include`](https://hatch.pypa.io/latest/config/build/#patterns) 41 | - Poetry [`exclude`](https://python-poetry.org/docs/pyproject/#exclude-and-include) to Hatch [`exclude`](https://hatch.pypa.io/latest/config/build/#patterns) 42 | 43 | !!! note 44 | 45 | Path rewriting, defined with `to` in `packages` for Poetry, is also migrated to Hatch by defining 46 | [sources](https://hatch.pypa.io/latest/config/build/#rewriting-paths) in wheel target. 47 | 48 | 49 | Once uv build backend is out of preview and considered stable, it will be used for the migration. 50 | 51 | ## Pipenv 52 | 53 | All existing [Pipenv](https://pipenv.pypa.io/en/stable/) metadata should be converted to uv when performing the 54 | migration: 55 | 56 | - [Dependencies and development dependencies](https://pipenv.pypa.io/en/stable/pipfile.html#example-pipfile) (PyPI, 57 | path, git, URL) 58 | - [Package category groups](https://pipenv.pypa.io/en/stable/pipfile.html#package-category-groups) 59 | - [Package indexes](https://pipenv.pypa.io/en/stable/indexes.html) 60 | - [Dependency markers](https://pipenv.pypa.io/en/stable/specifiers.html#specifying-basically-anything) 61 | - [Supported Python versions](https://pipenv.pypa.io/en/stable/advanced.html#automatic-python-installation) 62 | 63 | ## pip-tools 64 | 65 | Most [pip-tools](https://pip-tools.readthedocs.io/en/stable/) metadata is converted to uv when performing the migration. 66 | 67 | By default, `migrate-to-uv` will search for: 68 | 69 | - production dependencies in `requirements.in` 70 | - development dependencies in `requirements-dev.in` 71 | 72 | If your project uses different file names, or defines production and/or development dependencies across multiple files, 73 | you can specify the names of the files using [`--requirements-file`](usage-and-configuration.md#-requirements-file) and 74 | [`--dev-requirements-file`](usage-and-configuration.md#-dev-requirements-file) (both can be specified multiple times), 75 | for instance: 76 | 77 | ```bash 78 | migrate-to-uv \ 79 | --requirements-file requirements-prod.in \ 80 | --dev-requirements-file requirements-dev.in \ 81 | --dev-requirements-file requirements-docs.in 82 | ``` 83 | 84 | ### Missing features 85 | 86 | - Dependencies that do not follow [PEP 508](https://peps.python.org/pep-0508/) specification are not yet handled 87 | - References to other requirement files (e.g., `-r other-requirements.in`) are not supported, but the requirements file 88 | can manually be set with [`--requirements-file`](usage-and-configuration.md#-requirements-file) or 89 | [`--dev-requirements-file`](usage-and-configuration.md#-dev-requirements-file) 90 | - Index URLs are not yet migrated 91 | 92 | ## pip 93 | 94 | Most [pip](https://pip.pypa.io/en/stable/) metadata is converted to uv when performing the migration. 95 | 96 | By default, `migrate-to-uv` will search for: 97 | 98 | - production dependencies in `requirements.txt` 99 | - development dependencies in `requirements-dev.txt` 100 | 101 | If your project uses different file names, or defines production and/or development dependencies across multiple files, 102 | you can specify the names of the files [`--requirements-file`](usage-and-configuration.md#-requirements-file) and 103 | [`--dev-requirements-file`](usage-and-configuration.md#-dev-requirements-file) (both can be specified multiple times), 104 | for instance: 105 | 106 | ```bash 107 | migrate-to-uv \ 108 | --requirements-file requirements-prod.txt \ 109 | --dev-requirements-file requirements-dev.txt \ 110 | --dev-requirements-file requirements-docs.txt 111 | ``` 112 | 113 | ### Missing features 114 | 115 | - Dependencies that do not follow [PEP 508](https://peps.python.org/pep-0508/) specification are not yet handled 116 | - References to other requirement files (e.g., `-r other-requirements.txt`) are not supported, but the requirements file 117 | can manually be set with [`--requirements-file`](usage-and-configuration.md#-requirements-file) or 118 | [`--dev-requirements-file`](usage-and-configuration.md#-dev-requirements-file) 119 | - Index URLs are not yet migrated 120 | -------------------------------------------------------------------------------- /docs/usage-and-configuration.md: -------------------------------------------------------------------------------- 1 | # Usage and configuration 2 | 3 | ## Basic usage 4 | 5 | ```bash 6 | # With uv 7 | uvx migrate-to-uv 8 | 9 | # With pipx 10 | pipx run migrate-to-uv 11 | ``` 12 | 13 | ## Configuration 14 | 15 | ### Project path 16 | 17 | By default, `migrate-to-uv` uses the current directory to search for the project to migrate. If the project is in a 18 | different path, you can set the path to a directory as a positional argument, like so: 19 | 20 | ```bash 21 | # Relative path 22 | migrate-to-uv subdirectory 23 | 24 | # Absolute path 25 | migrate-to-uv /home/foo/project 26 | ``` 27 | 28 | ### Arguments 29 | 30 | While `migrate-to-uv` tries, as much as possible, to match what the original package manager defines for a project 31 | when migrating the metadata to uv, there are features that could be present in a package manager that does not exist in 32 | uv, or behave differently. Mainly for those reasons, `migrate-to-uv` offers a few options. 33 | 34 | #### `--dry-run` 35 | 36 | This runs the migration, but without modifying the files. Instead, it prints the changes that would have been made in 37 | the terminal. 38 | 39 | **Example**: 40 | 41 | ```bash 42 | migrate-to-uv --dry-run 43 | ``` 44 | 45 | #### `--skip-lock` 46 | 47 | By default, `migrate-to-uv` locks dependencies with `uv lock` at the end of the migration. This flag disables this 48 | behavior. 49 | 50 | **Example**: 51 | 52 | ```bash 53 | migrate-to-uv --skip-lock 54 | ``` 55 | 56 | #### `--skip-uv-checks` 57 | 58 | By default, `migrate-to-uv` will exit early if it sees that a project is already using `uv`. 59 | This flag disables that behavior, allowing `migrate-to-uv` to run on a `pyproject.toml` 60 | which already has `uv` configured. 61 | 62 | Note that the project must also have a valid non-`uv` package manager configured, 63 | or else it will fail to generate the `uv` configuration. 64 | 65 | **Example:** 66 | 67 | ```bash 68 | migrate-to-uv --skip-uv-checks 69 | ``` 70 | 71 | #### `--ignore-locked-versions` 72 | 73 | By default, when locking dependencies with `uv lock`, `migrate-to-uv` keeps dependencies to the versions they were 74 | locked to with the previous package manager, if it supports lock files, and if a lock file is found. This behavior can 75 | be disabled, in which case dependencies will be locked to the highest possible versions allowed by the dependencies 76 | constraints. 77 | 78 | **Example**: 79 | 80 | ```bash 81 | migrate-to-uv --ignore-locked-versions 82 | ``` 83 | 84 | #### `--replace-project-section` 85 | 86 | By default, existing data in `[project]` section of `pyproject.toml` is preserved when migrating. This flag allows 87 | completely replacing existing content. 88 | 89 | **Example**: 90 | 91 | ```bash 92 | migrate-to-uv --replace-project-section 93 | ``` 94 | 95 | #### `--package-manager` 96 | 97 | By default, `migrate-to-uv` tries to auto-detect the package manager based on the files (and their content) used by the 98 | package managers it supports. If auto-detection does not work in some cases, or if you prefer to explicitly specify the 99 | package manager, this option could be used. 100 | 101 | **Example**: 102 | 103 | ```bash 104 | migrate-to-uv --package-manager poetry 105 | ``` 106 | 107 | #### `--dependency-groups-strategy` 108 | 109 | Most package managers that support dependency groups install dependencies from all groups when performing installation. 110 | By default, uv will [only install `dev` one](https://docs.astral.sh/uv/concepts/projects/dependencies/#default-groups). 111 | 112 | In order to match the workflow in the current package manager as closely as possible, by default, `migrate-to-uv` will 113 | move each dependency group to its corresponding one in uv, and set all dependency groups in `default-groups` under 114 | `[tool.uv]` section (unless the only dependency group is `dev` one, as this is already uv's default). 115 | 116 | If this is not desirable, it is possible to change the strategy by using `--dependency-groups-strategy `, where 117 | `` can be one of the following: 118 | 119 | - `set-default-groups` (default): Move each dependency group to its corresponding uv dependency group, and add all 120 | dependency groups in `default-groups` under `[tool.uv]` section (unless the only dependency group is `dev` one, as 121 | this is already uv's default) 122 | - `include-in-dev`: Move each dependency group to its corresponding uv dependency group, and reference all dependency 123 | groups (others than `dev` one) in `dev` dependency group by using `{ include = "" }` 124 | - `keep-existing`: Move each dependency group to its corresponding uv dependency group, without any further action 125 | - `merge-into-dev`: Merge dependencies from all dependency groups into `dev` dependency group 126 | 127 | **Example**: 128 | 129 | ```bash 130 | migrate-to-uv --dependency-groups-strategy include-in-dev 131 | ``` 132 | 133 | #### `--requirements-file` 134 | 135 | Names of the production requirements files to look for, for projects using `pip` or `pip-tools`. The argument can be set 136 | multiple times, if there are multiple files. 137 | 138 | **Example**: 139 | 140 | ```bash 141 | migrate-to-uv --requirements-file requirements.txt --requirements-file more-requirements.txt 142 | ``` 143 | 144 | #### `--dev-requirements-file` 145 | 146 | Names of the development requirements files to look for, for projects using `pip` or `pip-tools`. The argument can be 147 | set multiple times, if there are multiple files. 148 | 149 | **Example**: 150 | 151 | ```bash 152 | migrate-to-uv --dev-requirements-file requirements-dev.txt --dev-requirements-file requirements-docs.txt 153 | ``` 154 | 155 | #### `--keep-current-data` 156 | 157 | Keep the current package manager data (lock file, sections in `pyproject.toml`, ...) after the migration, if you want to 158 | handle the cleaning yourself, or want to compare the differences first. 159 | 160 | ### Authentication for private indexes 161 | 162 | By default, `migrate-to-uv` generates `uv.lock` with `uv lock` to lock dependencies. If you currently use a package 163 | manager with private indexes, credentials will need to be set for locking to work properly. This can be done by setting 164 | the [same environment variables as uv expects for private indexes](https://docs.astral.sh/uv/configuration/indexes/#providing-credentials). 165 | 166 | Since the names of the indexes in uv should be the same as the ones in the current package manager before the migration, 167 | you should be able to adapt the environment variables based on what you previously used. 168 | 169 | For instance, if you currently use Poetry and have: 170 | 171 | ```toml 172 | [[tool.poetry.source]] 173 | name = "foo-bar" 174 | url = "https://private-index.example.com" 175 | priority = "supplementary" 176 | ``` 177 | 178 | Credentials would be set with the following environment variables: 179 | 180 | - `POETRY_HTTP_BASIC_FOO_BAR_USERNAME` 181 | - `POETRY_HTTP_BASIC_FOO_BAR_PASSWORD` 182 | 183 | For uv, this would translate to: 184 | 185 | - `UV_INDEX_FOO_BAR_USERNAME` 186 | - `UV_INDEX_FOO_BAR_PASSWORD` 187 | 188 | To forward those credentials to `migrate-to-uv`, you can either export them beforehand, or set the environment variables 189 | when invoking the command: 190 | 191 | ```bash 192 | # Either 193 | export UV_INDEX_FOO_BAR_USERNAME= 194 | export UV_INDEX_FOO_BAR_PASSWORD= 195 | migrate-to-uv 196 | 197 | # Or 198 | UV_INDEX_FOO_BAR_USERNAME= \ 199 | UV_INDEX_FOO_BAR_PASSWORD= \ 200 | migrate-to-uv 201 | ``` 202 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: migrate-to-uv 2 | edit_uri: edit/main/docs/ 3 | repo_name: mkniewallner/migrate-to-uv 4 | repo_url: https://github.com/mkniewallner/migrate-to-uv 5 | site_url: https://mkniewallner.github.io/migrate-to-uv 6 | site_description: Migrate to uv from another package manager. 7 | site_author: Mathieu Kniewallner 8 | 9 | nav: 10 | - Introduction: index.md 11 | - Usage and configuration: usage-and-configuration.md 12 | - Supported package managers: supported-package-managers.md 13 | - Changelog: CHANGELOG.md 14 | - Contributing: CONTRIBUTING.md 15 | 16 | plugins: 17 | - search 18 | 19 | theme: 20 | name: material 21 | features: 22 | - content.action.edit 23 | - content.code.copy 24 | - navigation.footer 25 | palette: 26 | - media: "(prefers-color-scheme)" 27 | toggle: 28 | icon: material/brightness-auto 29 | name: Switch to light mode 30 | - media: "(prefers-color-scheme: light)" 31 | scheme: default 32 | primary: deep purple 33 | accent: deep orange 34 | toggle: 35 | icon: material/brightness-7 36 | name: Switch to dark mode 37 | - media: "(prefers-color-scheme: dark)" 38 | scheme: slate 39 | primary: deep purple 40 | accent: deep orange 41 | toggle: 42 | icon: material/brightness-4 43 | name: Switch to system preferences 44 | icon: 45 | repo: fontawesome/brands/github 46 | 47 | extra: 48 | social: 49 | - icon: fontawesome/brands/github 50 | link: https://github.com/mkniewallner/migrate-to-uv 51 | - icon: fontawesome/brands/python 52 | link: https://pypi.org/project/migrate-to-uv/ 53 | 54 | markdown_extensions: 55 | - admonition 56 | - attr_list 57 | - md_in_html 58 | - pymdownx.details 59 | - pymdownx.superfences 60 | - toc: 61 | permalink: true 62 | - pymdownx.arithmatex: 63 | generic: true 64 | 65 | validation: 66 | omitted_files: warn 67 | absolute_links: warn 68 | unrecognized_links: warn 69 | anchors: warn 70 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.5,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "migrate-to-uv" 7 | version = "0.7.2" 8 | description = "" 9 | authors = [{ name = "Mathieu Kniewallner", email = "mathieu.kniewallner@gmail.com" }] 10 | requires-python = ">=3.8" 11 | license = "MIT" 12 | readme = "README.md" 13 | keywords = [ 14 | "uv", 15 | "migrate", 16 | "poetry", 17 | "pipenv", 18 | ] 19 | classifiers = [ 20 | "Development Status :: 3 - Alpha", 21 | "Environment :: Console", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: MIT License", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Programming Language :: Python :: 3 :: Only", 33 | "Programming Language :: Rust", 34 | "Topic :: Software Development :: Libraries", 35 | ] 36 | 37 | [project.urls] 38 | Documentation = "https://mkniewallner.github.io/migrate-to-uv/" 39 | Repository = "https://github.com/mkniewallner/migrate-to-uv" 40 | Changelog = "https://github.com/mkniewallner/migrate-to-uv/blob/main/CHANGELOG.md" 41 | Funding = "https://github.com/sponsors/mkniewallner" 42 | 43 | [dependency-groups] 44 | docs = [ 45 | "mkdocs==1.6.1", 46 | "mkdocs-material==9.6.14", 47 | ] 48 | 49 | [tool.maturin] 50 | bindings = "bin" 51 | module-name = "migrate_to_uv" 52 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.87" 3 | -------------------------------------------------------------------------------- /scripts/bump-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | version=$1 6 | 7 | sed -i "s/^version = \".*\"/version = \"${version}\"/" Cargo.toml pyproject.toml 8 | sed -i "s/^## Unreleased/## ${version} - $(date +%F)/" CHANGELOG.md 9 | cargo update migrate-to-uv 10 | uv lock --upgrade-package migrate-to-uv 11 | -------------------------------------------------------------------------------- /src/bin/migrate-to-uv.rs: -------------------------------------------------------------------------------- 1 | use migrate_to_uv::main as migrate_to_uv_main; 2 | 3 | fn main() { 4 | migrate_to_uv_main(); 5 | } 6 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use crate::converters::{ConverterOptions, DependencyGroupsStrategy}; 2 | use crate::detector::{PackageManager, get_converter}; 3 | use crate::logger; 4 | use clap::Parser; 5 | use clap::builder::Styles; 6 | use clap::builder::styling::{AnsiColor, Effects}; 7 | use clap_verbosity_flag::{InfoLevel, Verbosity}; 8 | use log::error; 9 | use std::path::PathBuf; 10 | use std::process; 11 | 12 | const STYLES: Styles = Styles::styled() 13 | .header(AnsiColor::Green.on_default().effects(Effects::BOLD)) 14 | .usage(AnsiColor::Green.on_default().effects(Effects::BOLD)) 15 | .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) 16 | .placeholder(AnsiColor::Cyan.on_default()) 17 | .error(AnsiColor::Red.on_default().effects(Effects::BOLD)) 18 | .valid(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) 19 | .invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD)); 20 | 21 | #[derive(Parser)] 22 | #[command(version)] 23 | #[command(about = "Migrate a project to uv from another package manager.", long_about = None)] 24 | #[command(styles = STYLES)] 25 | #[allow(clippy::struct_excessive_bools)] 26 | struct Cli { 27 | #[arg(default_value = ".", help = "Path to the project to migrate")] 28 | path: PathBuf, 29 | #[arg( 30 | long, 31 | help = "Shows what changes would be applied, without modifying files" 32 | )] 33 | dry_run: bool, 34 | #[arg( 35 | long, 36 | help = "Do not lock dependencies with uv at the end of the migration" 37 | )] 38 | skip_lock: bool, 39 | #[arg( 40 | long, 41 | help = "Skip checks for whether or not the project is already using uv" 42 | )] 43 | skip_uv_checks: bool, 44 | #[arg( 45 | long, 46 | help = "Ignore current locked versions of dependencies when generating `uv.lock`" 47 | )] 48 | ignore_locked_versions: bool, 49 | #[arg( 50 | long, 51 | help = "Replace existing data in `[project]` section of `pyproject.toml` instead of keeping existing fields" 52 | )] 53 | replace_project_section: bool, 54 | #[arg( 55 | long, 56 | help = "Enforce a specific package manager instead of auto-detecting it" 57 | )] 58 | package_manager: Option, 59 | #[arg( 60 | long, 61 | default_value = "set-default-groups", 62 | help = "Strategy to use when migrating dependency groups" 63 | )] 64 | dependency_groups_strategy: DependencyGroupsStrategy, 65 | #[arg(long, help = "Keep data from current package manager")] 66 | keep_current_data: bool, 67 | #[arg(long, default_values = vec!["requirements.txt"], help = "Requirements file to migrate")] 68 | requirements_file: Vec, 69 | #[arg(long, default_values = vec!["requirements-dev.txt"], help = "Development requirements file to migrate")] 70 | dev_requirements_file: Vec, 71 | #[command(flatten)] 72 | verbose: Verbosity, 73 | } 74 | 75 | pub fn cli() { 76 | let cli = Cli::parse(); 77 | 78 | logger::configure(cli.verbose); 79 | 80 | let converter_options = ConverterOptions { 81 | project_path: PathBuf::from(&cli.path), 82 | dry_run: cli.dry_run, 83 | skip_lock: cli.skip_lock, 84 | skip_uv_checks: cli.skip_uv_checks, 85 | ignore_locked_versions: cli.ignore_locked_versions, 86 | replace_project_section: cli.replace_project_section, 87 | keep_old_metadata: cli.keep_current_data, 88 | dependency_groups_strategy: cli.dependency_groups_strategy, 89 | }; 90 | 91 | match get_converter( 92 | &converter_options, 93 | cli.requirements_file, 94 | cli.dev_requirements_file, 95 | cli.package_manager, 96 | ) { 97 | Ok(converter) => { 98 | converter.convert_to_uv(); 99 | } 100 | Err(error) => { 101 | error!("{error}"); 102 | process::exit(1); 103 | } 104 | } 105 | 106 | process::exit(0) 107 | } 108 | -------------------------------------------------------------------------------- /src/converters/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::converters::pyproject_updater::PyprojectUpdater; 2 | use crate::schema::pep_621::Project; 3 | use crate::schema::pyproject::DependencyGroupSpecification; 4 | use indexmap::IndexMap; 5 | use log::{error, info, warn}; 6 | use owo_colors::OwoColorize; 7 | use std::any::Any; 8 | use std::fmt::Debug; 9 | use std::format; 10 | use std::fs::{File, remove_file}; 11 | use std::io::{ErrorKind, Write}; 12 | use std::path::{Path, PathBuf}; 13 | use std::process::{Command, Stdio}; 14 | use toml_edit::DocumentMut; 15 | 16 | pub mod pip; 17 | pub mod pipenv; 18 | pub mod poetry; 19 | mod pyproject_updater; 20 | 21 | type DependencyGroupsAndDefaultGroups = ( 22 | Option>>, 23 | Option>, 24 | ); 25 | 26 | #[derive(Debug, PartialEq, Eq, Clone)] 27 | #[allow(clippy::struct_excessive_bools)] 28 | pub struct ConverterOptions { 29 | pub project_path: PathBuf, 30 | pub dry_run: bool, 31 | pub skip_lock: bool, 32 | pub skip_uv_checks: bool, 33 | pub ignore_locked_versions: bool, 34 | pub replace_project_section: bool, 35 | pub keep_old_metadata: bool, 36 | pub dependency_groups_strategy: DependencyGroupsStrategy, 37 | } 38 | 39 | /// Converts a project from a package manager to uv. 40 | pub trait Converter: Any + Debug { 41 | /// Performs the conversion from the current package manager to uv. 42 | fn convert_to_uv(&self) { 43 | let pyproject_path = self.get_project_path().join("pyproject.toml"); 44 | let updated_pyproject_string = self.build_uv_pyproject(); 45 | 46 | if self.is_dry_run() { 47 | info!( 48 | "{}\n{}", 49 | "Migrated pyproject.toml:".bold(), 50 | updated_pyproject_string 51 | ); 52 | return; 53 | } 54 | 55 | let mut pyproject_file = File::create(&pyproject_path).unwrap(); 56 | 57 | pyproject_file 58 | .write_all(updated_pyproject_string.as_bytes()) 59 | .unwrap(); 60 | 61 | self.delete_migrated_files().unwrap(); 62 | self.lock_dependencies(); 63 | self.remove_constraint_dependencies(updated_pyproject_string); 64 | 65 | info!( 66 | "{}", 67 | format!( 68 | "Successfully migrated project from {} to uv!\n", 69 | self.get_package_manager_name() 70 | ) 71 | .bold() 72 | .green() 73 | ); 74 | } 75 | 76 | /// Build `pyproject.toml` for uv package manager based on current package manager data. 77 | fn build_uv_pyproject(&self) -> String; 78 | 79 | /// Build PEP 621 `[project]` section, keeping existing fields if the section is already 80 | /// defined, unless user has chosen to replace existing section. 81 | fn build_project(&self, current_project: Option, project: Project) -> Project { 82 | if self.replace_project_section() { 83 | return project; 84 | } 85 | 86 | let Some(current_project) = current_project else { 87 | return project; 88 | }; 89 | 90 | Project { 91 | name: current_project.name.or(project.name), 92 | version: current_project.version.or(project.version), 93 | description: current_project.description.or(project.description), 94 | authors: current_project.authors.or(project.authors), 95 | requires_python: current_project.requires_python.or(project.requires_python), 96 | readme: current_project.readme.or(project.readme), 97 | license: current_project.license.or(project.license), 98 | maintainers: current_project.maintainers.or(project.maintainers), 99 | keywords: current_project.keywords.or(project.keywords), 100 | classifiers: current_project.classifiers.or(project.classifiers), 101 | dependencies: current_project.dependencies.or(project.dependencies), 102 | optional_dependencies: current_project 103 | .optional_dependencies 104 | .or(project.optional_dependencies), 105 | urls: current_project.urls.or(project.urls), 106 | scripts: current_project.scripts.or(project.scripts), 107 | gui_scripts: current_project.gui_scripts.or(project.gui_scripts), 108 | entry_points: current_project.entry_points.or(project.entry_points), 109 | remaining_fields: current_project.remaining_fields, 110 | } 111 | } 112 | 113 | /// Name of the current package manager. 114 | fn get_package_manager_name(&self) -> String; 115 | 116 | /// Get the options chosen by the user to perform the migration, such as the project path, 117 | /// whether locking should be performed at the end of the migration, ... 118 | fn get_converter_options(&self) -> &ConverterOptions; 119 | 120 | /// Path to the project to migrate. 121 | fn get_project_path(&self) -> PathBuf { 122 | self.get_converter_options().clone().project_path 123 | } 124 | 125 | /// Whether to perform the migration in dry-run mode, meaning that the changes are printed out 126 | /// instead of made for real. 127 | fn is_dry_run(&self) -> bool { 128 | self.get_converter_options().dry_run 129 | } 130 | 131 | /// Whether to skip dependencies locking at the end of the migration. 132 | fn skip_lock(&self) -> bool { 133 | self.get_converter_options().skip_lock 134 | } 135 | 136 | /// Whether to replace existing `[project]` section of `pyproject.toml`, or to keep existing 137 | /// fields. 138 | fn replace_project_section(&self) -> bool { 139 | self.get_converter_options().replace_project_section 140 | } 141 | 142 | /// Whether to keep current package manager data at the end of the migration. 143 | fn keep_old_metadata(&self) -> bool { 144 | self.get_converter_options().keep_old_metadata 145 | } 146 | 147 | /// Whether to keep versions locked in the current package manager (if it supports lock files) 148 | /// when locking dependencies with uv. 149 | fn respect_locked_versions(&self) -> bool { 150 | !self.get_converter_options().ignore_locked_versions 151 | } 152 | 153 | /// Dependency groups strategy to use when writing development dependencies in dependency 154 | /// groups. 155 | fn get_dependency_groups_strategy(&self) -> DependencyGroupsStrategy { 156 | self.get_converter_options().dependency_groups_strategy 157 | } 158 | 159 | /// List of files tied to the current package manager to delete at the end of the migration. 160 | fn get_migrated_files_to_delete(&self) -> Vec; 161 | 162 | /// Delete files tied to the current package manager at the end of the migration, unless user 163 | /// has chosen to keep the current package manager data. 164 | fn delete_migrated_files(&self) -> std::io::Result<()> { 165 | if self.keep_old_metadata() { 166 | return Ok(()); 167 | } 168 | 169 | for file in self.get_migrated_files_to_delete() { 170 | let path = self.get_project_path().join(file); 171 | 172 | if path.exists() { 173 | remove_file(path)?; 174 | } 175 | } 176 | 177 | Ok(()) 178 | } 179 | 180 | /// Lock dependencies with uv, unless user has explicitly opted out of locking dependencies. 181 | fn lock_dependencies(&self) { 182 | if !self.skip_lock() && lock_dependencies(self.get_project_path().as_ref(), false).is_err() 183 | { 184 | warn!( 185 | "An error occurred when locking dependencies, so \"{}\" was not created.", 186 | "uv.lock".bold() 187 | ); 188 | } 189 | } 190 | 191 | /// Get dependencies constraints to set in `constraint-dependencies` under `[tool.uv]` section, 192 | /// to keep dependencies locked to the same versions as they are with the current package 193 | /// manager. 194 | fn get_constraint_dependencies(&self) -> Option>; 195 | 196 | /// Remove `constraint-dependencies` from `[tool.uv]` in `pyproject.toml`, unless user has 197 | /// opted out of keeping versions locked in the current package manager. 198 | /// 199 | /// Also lock dependencies, to remove `constraints` from `[manifest]` in lock file, unless user 200 | /// has opted out of locking dependencies. 201 | fn remove_constraint_dependencies(&self, updated_pyproject_toml: String) { 202 | if !self.respect_locked_versions() { 203 | return; 204 | } 205 | 206 | let mut pyproject_updater = PyprojectUpdater { 207 | pyproject: &mut updated_pyproject_toml.parse::().unwrap(), 208 | }; 209 | if let Some(updated_pyproject) = pyproject_updater.remove_constraint_dependencies() { 210 | let mut pyproject_file = 211 | File::create(self.get_project_path().join("pyproject.toml")).unwrap(); 212 | pyproject_file 213 | .write_all(updated_pyproject.to_string().as_bytes()) 214 | .unwrap(); 215 | 216 | // Lock dependencies a second time, to remove constraints from lock file. 217 | if !self.skip_lock() 218 | && lock_dependencies(self.get_project_path().as_ref(), true).is_err() 219 | { 220 | warn!("An error occurred when locking dependencies after removing constraints."); 221 | } 222 | } 223 | } 224 | } 225 | 226 | /// Lock dependencies with uv by running `uv lock` command. 227 | pub fn lock_dependencies(project_path: &Path, is_removing_constraints: bool) -> Result<(), ()> { 228 | const UV_EXECUTABLE: &str = "uv"; 229 | 230 | match Command::new(UV_EXECUTABLE) 231 | .stdout(Stdio::null()) 232 | .stderr(Stdio::null()) 233 | .spawn() 234 | { 235 | Ok(_) => { 236 | info!( 237 | "Locking dependencies with \"{}\"{}...", 238 | format!("{UV_EXECUTABLE} lock").bold(), 239 | if is_removing_constraints { 240 | " again to remove constraints" 241 | } else { 242 | "" 243 | } 244 | ); 245 | 246 | Command::new(UV_EXECUTABLE) 247 | .arg("lock") 248 | .current_dir(project_path) 249 | .spawn() 250 | .map_or_else( 251 | |_| { 252 | error!( 253 | "Could not invoke \"{}\" command.", 254 | format!("{UV_EXECUTABLE} lock").bold() 255 | ); 256 | Err(()) 257 | }, 258 | |lock| match lock.wait_with_output() { 259 | Ok(output) => { 260 | if output.status.success() { 261 | Ok(()) 262 | } else { 263 | Err(()) 264 | } 265 | } 266 | Err(e) => { 267 | error!("{e}"); 268 | Err(()) 269 | } 270 | }, 271 | ) 272 | } 273 | Err(e) if e.kind() == ErrorKind::NotFound => { 274 | warn!( 275 | "Could not find \"{}\" executable, skipping locking dependencies.", 276 | UV_EXECUTABLE.bold() 277 | ); 278 | Ok(()) 279 | } 280 | Err(e) => { 281 | error!("{e}"); 282 | Err(()) 283 | } 284 | } 285 | } 286 | 287 | #[derive(clap::ValueEnum, Clone, Copy, PartialEq, Eq, Debug)] 288 | pub enum DependencyGroupsStrategy { 289 | SetDefaultGroups, 290 | IncludeInDev, 291 | KeepExisting, 292 | MergeIntoDev, 293 | } 294 | -------------------------------------------------------------------------------- /src/converters/pip/dependencies.rs: -------------------------------------------------------------------------------- 1 | use log::warn; 2 | use pep508_rs::Requirement; 3 | use std::fs; 4 | use std::path::Path; 5 | use std::str::FromStr; 6 | use url::Url; 7 | 8 | pub fn get(project_path: &Path, requirements_files: Vec) -> Option> { 9 | let mut dependencies: Vec = Vec::new(); 10 | 11 | for requirements_file in requirements_files { 12 | let requirements_content = 13 | fs::read_to_string(project_path.join(requirements_file)).unwrap(); 14 | 15 | for line in requirements_content.lines() { 16 | let line = line.trim(); 17 | 18 | // Ignore empty lines, comments. Also ignore lines starting with `-` to ignore arguments 19 | // (package names cannot start with a hyphen). No argument is supported yet, so we can 20 | // simply ignore all of them. 21 | if line.is_empty() || line.starts_with('#') || line.starts_with('-') { 22 | continue; 23 | } 24 | 25 | let dependency = match line.split_once(" #") { 26 | Some((dependency, _)) => dependency, 27 | None => line, 28 | }; 29 | 30 | let dependency_specification = Requirement::::from_str(dependency); 31 | 32 | if let Ok(dependency_specification) = dependency_specification { 33 | dependencies.push(dependency_specification.to_string()); 34 | } else { 35 | warn!( 36 | "Could not parse the following dependency specification as a PEP 508 one: {line}" 37 | ); 38 | } 39 | } 40 | } 41 | 42 | if dependencies.is_empty() { 43 | return None; 44 | } 45 | Some(dependencies) 46 | } 47 | -------------------------------------------------------------------------------- /src/converters/pip/mod.rs: -------------------------------------------------------------------------------- 1 | mod dependencies; 2 | 3 | use crate::converters::Converter; 4 | use crate::converters::ConverterOptions; 5 | use crate::converters::pyproject_updater::PyprojectUpdater; 6 | use crate::schema::pep_621::Project; 7 | use crate::schema::pyproject::{DependencyGroupSpecification, PyProject}; 8 | use crate::schema::uv::Uv; 9 | use crate::toml::PyprojectPrettyFormatter; 10 | use indexmap::IndexMap; 11 | use std::default::Default; 12 | use std::fs; 13 | use toml_edit::DocumentMut; 14 | use toml_edit::visit_mut::VisitMut; 15 | 16 | #[derive(Debug, PartialEq, Eq)] 17 | pub struct Pip { 18 | pub converter_options: ConverterOptions, 19 | pub requirements_files: Vec, 20 | pub dev_requirements_files: Vec, 21 | pub is_pip_tools: bool, 22 | } 23 | 24 | impl Converter for Pip { 25 | fn build_uv_pyproject(&self) -> String { 26 | let pyproject_toml_content = 27 | fs::read_to_string(self.get_project_path().join("pyproject.toml")).unwrap_or_default(); 28 | let pyproject: PyProject = toml::from_str(pyproject_toml_content.as_str()).unwrap(); 29 | 30 | let dev_dependencies = dependencies::get( 31 | &self.get_project_path(), 32 | self.dev_requirements_files.clone(), 33 | ); 34 | 35 | let dependency_groups = dev_dependencies.map(|dependencies| { 36 | IndexMap::from([( 37 | "dev".to_string(), 38 | dependencies 39 | .iter() 40 | .map(|dep| DependencyGroupSpecification::String(dep.to_string())) 41 | .collect(), 42 | )]) 43 | }); 44 | 45 | let project = Project { 46 | // "name" is required by uv. 47 | name: Some(String::new()), 48 | // "version" is required by uv. 49 | version: Some("0.0.1".to_string()), 50 | dependencies: dependencies::get( 51 | &self.get_project_path(), 52 | self.requirements_files.clone(), 53 | ), 54 | ..Default::default() 55 | }; 56 | 57 | let uv = Uv { 58 | package: Some(false), 59 | constraint_dependencies: self.get_constraint_dependencies(), 60 | ..Default::default() 61 | }; 62 | 63 | let pyproject_toml_content = 64 | fs::read_to_string(self.get_project_path().join("pyproject.toml")).unwrap_or_default(); 65 | let mut updated_pyproject = pyproject_toml_content.parse::().unwrap(); 66 | let mut pyproject_updater = PyprojectUpdater { 67 | pyproject: &mut updated_pyproject, 68 | }; 69 | 70 | pyproject_updater.insert_pep_621(&self.build_project(pyproject.project, project)); 71 | pyproject_updater.insert_dependency_groups(dependency_groups.as_ref()); 72 | pyproject_updater.insert_uv(&uv); 73 | 74 | let mut visitor = PyprojectPrettyFormatter::default(); 75 | visitor.visit_document_mut(&mut updated_pyproject); 76 | 77 | updated_pyproject.to_string() 78 | } 79 | 80 | fn get_package_manager_name(&self) -> String { 81 | if self.is_pip_tools { 82 | return "pip-tools".to_string(); 83 | } 84 | "pip".to_string() 85 | } 86 | 87 | fn get_converter_options(&self) -> &ConverterOptions { 88 | &self.converter_options 89 | } 90 | 91 | fn respect_locked_versions(&self) -> bool { 92 | // There are no locked dependencies for pip, so locked versions are only respected for 93 | // pip-tools. 94 | self.is_pip_tools && !self.get_converter_options().ignore_locked_versions 95 | } 96 | 97 | fn get_migrated_files_to_delete(&self) -> Vec { 98 | let mut files_to_delete: Vec = Vec::new(); 99 | 100 | for requirements_file in self 101 | .requirements_files 102 | .iter() 103 | .chain(&self.dev_requirements_files) 104 | { 105 | files_to_delete.push(requirements_file.to_string()); 106 | 107 | // For pip-tools, also delete `.txt` files generated from `.in` files. 108 | if self.is_pip_tools { 109 | files_to_delete.push(requirements_file.replace(".in", ".txt")); 110 | } 111 | } 112 | 113 | files_to_delete 114 | } 115 | 116 | fn get_constraint_dependencies(&self) -> Option> { 117 | if !self.is_pip_tools || self.is_dry_run() || !self.respect_locked_versions() { 118 | return None; 119 | } 120 | 121 | if let Some(dependencies) = dependencies::get( 122 | self.get_project_path().as_path(), 123 | self.requirements_files 124 | .clone() 125 | .into_iter() 126 | .chain(self.dev_requirements_files.clone()) 127 | .map(|f| f.replace(".in", ".txt")) 128 | .collect(), 129 | ) { 130 | if dependencies.is_empty() { 131 | return None; 132 | } 133 | return Some(dependencies); 134 | } 135 | None 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/converters/pipenv/dependencies.rs: -------------------------------------------------------------------------------- 1 | use crate::converters::{DependencyGroupsAndDefaultGroups, DependencyGroupsStrategy}; 2 | use crate::schema; 3 | use crate::schema::pipenv::{DependencySpecification, KeywordMarkers}; 4 | use crate::schema::pyproject::DependencyGroupSpecification; 5 | use crate::schema::uv::{SourceContainer, SourceIndex}; 6 | use indexmap::IndexMap; 7 | 8 | pub fn get( 9 | pipenv_dependencies: Option<&IndexMap>, 10 | uv_source_index: &mut IndexMap, 11 | ) -> Option> { 12 | Some( 13 | pipenv_dependencies? 14 | .iter() 15 | .map(|(name, specification)| { 16 | let source_index = match specification { 17 | DependencySpecification::Map { 18 | index: Some(index), .. 19 | } => Some(SourceContainer::SourceIndex(SourceIndex { 20 | index: Some(index.to_string()), 21 | ..Default::default() 22 | })), 23 | DependencySpecification::Map { 24 | path: Some(path), 25 | editable, 26 | .. 27 | } => Some(SourceContainer::SourceIndex(SourceIndex { 28 | path: Some(path.to_string()), 29 | editable: *editable, 30 | ..Default::default() 31 | })), 32 | DependencySpecification::Map { 33 | git: Some(git), 34 | ref_, 35 | .. 36 | } => Some(SourceContainer::SourceIndex(SourceIndex { 37 | git: Some(git.clone()), 38 | rev: ref_.clone(), 39 | ..Default::default() 40 | })), 41 | _ => None, 42 | }; 43 | 44 | if let Some(source_index) = source_index { 45 | uv_source_index.insert(name.to_string(), source_index); 46 | } 47 | 48 | match specification { 49 | DependencySpecification::String(spec) => match spec.as_str() { 50 | "*" => name.to_string(), 51 | _ => format!("{name}{spec}"), 52 | }, 53 | DependencySpecification::Map { 54 | version, 55 | extras, 56 | markers, 57 | keyword_markers, 58 | .. 59 | } => { 60 | let mut pep_508_version = name.clone(); 61 | let mut combined_markers: Vec = 62 | get_keyword_markers(keyword_markers); 63 | 64 | if let Some(extras) = extras { 65 | pep_508_version.push_str(format!("[{}]", extras.join(", ")).as_str()); 66 | } 67 | 68 | if let Some(version) = version { 69 | match version.as_str() { 70 | "*" => (), 71 | _ => pep_508_version.push_str(version), 72 | } 73 | } 74 | 75 | if let Some(markers) = markers { 76 | combined_markers.push(markers.to_string()); 77 | } 78 | 79 | if !combined_markers.is_empty() { 80 | pep_508_version.push_str( 81 | format!(" ; {}", combined_markers.join(" and ")).as_str(), 82 | ); 83 | } 84 | 85 | pep_508_version.to_string() 86 | } 87 | } 88 | }) 89 | .collect(), 90 | ) 91 | } 92 | 93 | fn get_keyword_markers(keyword_markers: &KeywordMarkers) -> Vec { 94 | let mut markers: Vec = Vec::new(); 95 | 96 | macro_rules! push_marker { 97 | ($field:expr, $name:expr) => { 98 | if let Some(value) = &$field { 99 | markers.push(format!("{} {}", $name, value)); 100 | } 101 | }; 102 | } 103 | 104 | push_marker!(keyword_markers.os_name, "os_name"); 105 | push_marker!(keyword_markers.sys_platform, "sys_platform"); 106 | push_marker!(keyword_markers.platform_machine, "platform_machine"); 107 | push_marker!( 108 | keyword_markers.platform_python_implementation, 109 | "platform_python_implementation" 110 | ); 111 | push_marker!(keyword_markers.platform_release, "platform_release"); 112 | push_marker!(keyword_markers.platform_system, "platform_system"); 113 | push_marker!(keyword_markers.platform_version, "platform_version"); 114 | push_marker!(keyword_markers.python_version, "python_version"); 115 | push_marker!(keyword_markers.python_full_version, "python_full_version"); 116 | push_marker!(keyword_markers.implementation_name, "implementation_name"); 117 | push_marker!( 118 | keyword_markers.implementation_version, 119 | "implementation_version" 120 | ); 121 | 122 | markers 123 | } 124 | 125 | pub fn get_dependency_groups_and_default_groups( 126 | pipfile: &schema::pipenv::Pipfile, 127 | uv_source_index: &mut IndexMap, 128 | dependency_groups_strategy: DependencyGroupsStrategy, 129 | ) -> DependencyGroupsAndDefaultGroups { 130 | let mut dependency_groups: IndexMap> = 131 | IndexMap::new(); 132 | let mut default_groups: Vec = Vec::new(); 133 | 134 | // Add dependencies from legacy `[dev-packages]` into `dev` dependency group. 135 | if let Some(dev_dependencies) = &pipfile.dev_packages { 136 | dependency_groups.insert( 137 | "dev".to_string(), 138 | get(Some(dev_dependencies), uv_source_index) 139 | .unwrap_or_default() 140 | .into_iter() 141 | .map(DependencyGroupSpecification::String) 142 | .collect(), 143 | ); 144 | } 145 | 146 | // Add dependencies from `[]` into `` dependency group, 147 | // unless `MergeIntoDev` strategy is used, in which case we add them into `dev` dependency 148 | // group. 149 | if let Some(category_group) = &pipfile.category_groups { 150 | for (group, dependency_specification) in category_group { 151 | dependency_groups 152 | .entry(match dependency_groups_strategy { 153 | DependencyGroupsStrategy::MergeIntoDev => "dev".to_string(), 154 | _ => group.to_string(), 155 | }) 156 | .or_default() 157 | .extend( 158 | get(Some(dependency_specification), uv_source_index) 159 | .unwrap_or_default() 160 | .into_iter() 161 | .map(DependencyGroupSpecification::String), 162 | ); 163 | } 164 | 165 | match dependency_groups_strategy { 166 | // When using `SetDefaultGroups` strategy, all dependency groups are referenced in 167 | // `default-groups` under `[tool.uv]` section. If we only have `dev` dependency group, 168 | // do not set `default-groups`, as this is already uv's default. 169 | DependencyGroupsStrategy::SetDefaultGroups => { 170 | if !dependency_groups.keys().eq(["dev"]) { 171 | default_groups.extend(dependency_groups.keys().map(ToString::to_string)); 172 | } 173 | } 174 | // When using `IncludeInDev` strategy, dependency groups (except `dev` one) are 175 | // referenced from `dev` dependency group with `{ include = "" }`. 176 | DependencyGroupsStrategy::IncludeInDev => { 177 | dependency_groups 178 | .entry("dev".to_string()) 179 | .or_default() 180 | .extend(category_group.keys().filter(|&k| k != "dev").map(|g| { 181 | DependencyGroupSpecification::Map { 182 | include: Some(g.to_string()), 183 | } 184 | })); 185 | } 186 | _ => (), 187 | } 188 | } 189 | 190 | if dependency_groups.is_empty() { 191 | return (None, None); 192 | } 193 | 194 | ( 195 | Some(dependency_groups), 196 | if default_groups.is_empty() { 197 | None 198 | } else { 199 | Some(default_groups) 200 | }, 201 | ) 202 | } 203 | -------------------------------------------------------------------------------- /src/converters/pipenv/mod.rs: -------------------------------------------------------------------------------- 1 | mod dependencies; 2 | mod project; 3 | mod sources; 4 | 5 | use crate::converters::Converter; 6 | use crate::converters::ConverterOptions; 7 | use crate::converters::pyproject_updater::PyprojectUpdater; 8 | use crate::schema::pep_621::Project; 9 | use crate::schema::pipenv::{PipenvLock, Pipfile}; 10 | use crate::schema::pyproject::PyProject; 11 | use crate::schema::uv::{SourceContainer, Uv}; 12 | use crate::toml::PyprojectPrettyFormatter; 13 | use indexmap::IndexMap; 14 | use log::warn; 15 | use owo_colors::OwoColorize; 16 | use std::default::Default; 17 | use std::fs; 18 | use toml_edit::DocumentMut; 19 | use toml_edit::visit_mut::VisitMut; 20 | 21 | #[derive(Debug, PartialEq, Eq)] 22 | pub struct Pipenv { 23 | pub converter_options: ConverterOptions, 24 | } 25 | 26 | impl Converter for Pipenv { 27 | fn build_uv_pyproject(&self) -> String { 28 | let pyproject_toml_content = 29 | fs::read_to_string(self.get_project_path().join("pyproject.toml")).unwrap_or_default(); 30 | let pyproject: PyProject = toml::from_str(pyproject_toml_content.as_str()).unwrap(); 31 | 32 | let pipfile_content = fs::read_to_string(self.get_project_path().join("Pipfile")).unwrap(); 33 | let pipfile: Pipfile = toml::from_str(pipfile_content.as_str()).unwrap(); 34 | 35 | let mut uv_source_index: IndexMap = IndexMap::new(); 36 | let (dependency_groups, uv_default_groups) = 37 | dependencies::get_dependency_groups_and_default_groups( 38 | &pipfile, 39 | &mut uv_source_index, 40 | self.get_dependency_groups_strategy(), 41 | ); 42 | 43 | let project = Project { 44 | // "name" is required by uv. 45 | name: Some(String::new()), 46 | // "version" is required by uv. 47 | version: Some("0.0.1".to_string()), 48 | requires_python: project::get_requires_python(pipfile.requires), 49 | dependencies: dependencies::get(pipfile.packages.as_ref(), &mut uv_source_index), 50 | ..Default::default() 51 | }; 52 | 53 | let uv = Uv { 54 | package: Some(false), 55 | index: sources::get_indexes(pipfile.source), 56 | sources: if uv_source_index.is_empty() { 57 | None 58 | } else { 59 | Some(uv_source_index) 60 | }, 61 | default_groups: uv_default_groups, 62 | constraint_dependencies: self.get_constraint_dependencies(), 63 | }; 64 | 65 | let pyproject_toml_content = 66 | fs::read_to_string(self.get_project_path().join("pyproject.toml")).unwrap_or_default(); 67 | let mut updated_pyproject = pyproject_toml_content.parse::().unwrap(); 68 | let mut pyproject_updater = PyprojectUpdater { 69 | pyproject: &mut updated_pyproject, 70 | }; 71 | 72 | pyproject_updater.insert_pep_621(&self.build_project(pyproject.project, project)); 73 | pyproject_updater.insert_dependency_groups(dependency_groups.as_ref()); 74 | pyproject_updater.insert_uv(&uv); 75 | 76 | let mut visitor = PyprojectPrettyFormatter::default(); 77 | visitor.visit_document_mut(&mut updated_pyproject); 78 | 79 | updated_pyproject.to_string() 80 | } 81 | 82 | fn get_package_manager_name(&self) -> String { 83 | "Pipenv".to_string() 84 | } 85 | 86 | fn get_converter_options(&self) -> &ConverterOptions { 87 | &self.converter_options 88 | } 89 | 90 | fn get_migrated_files_to_delete(&self) -> Vec { 91 | vec!["Pipfile".to_string(), "Pipfile.lock".to_string()] 92 | } 93 | 94 | fn get_constraint_dependencies(&self) -> Option> { 95 | let pipenv_lock_path = self.get_project_path().join("Pipfile.lock"); 96 | 97 | if self.is_dry_run() || !self.respect_locked_versions() || !pipenv_lock_path.exists() { 98 | return None; 99 | } 100 | 101 | let pipenv_lock_content = fs::read_to_string(pipenv_lock_path).unwrap(); 102 | let Ok(pipenv_lock) = serde_json::from_str::(pipenv_lock_content.as_str()) 103 | else { 104 | warn!( 105 | "Could not parse \"{}\", dependencies will not be kept to their current locked versions.", 106 | "Pipfile.lock".bold() 107 | ); 108 | return None; 109 | }; 110 | 111 | let constraint_dependencies: Vec = pipenv_lock 112 | .category_groups 113 | .unwrap_or_default() 114 | .values() 115 | .flatten() 116 | .map(|(name, spec)| format!("{}{}", name, spec.version)) 117 | .collect(); 118 | 119 | if constraint_dependencies.is_empty() { 120 | None 121 | } else { 122 | Some(constraint_dependencies) 123 | } 124 | } 125 | } 126 | 127 | #[cfg(test)] 128 | mod tests { 129 | use super::*; 130 | use crate::converters::DependencyGroupsStrategy; 131 | use std::fs::File; 132 | use std::io::Write; 133 | use std::path::PathBuf; 134 | use tempfile::tempdir; 135 | 136 | #[test] 137 | fn test_perform_migration_python_full_version() { 138 | let tmp_dir = tempdir().unwrap(); 139 | let project_path = tmp_dir.path(); 140 | 141 | let pipfile_content = r#" 142 | [requires] 143 | python_full_version = "3.13.1" 144 | "#; 145 | 146 | let mut pipfile_file = File::create(project_path.join("Pipfile")).unwrap(); 147 | pipfile_file.write_all(pipfile_content.as_bytes()).unwrap(); 148 | 149 | let pipenv = Pipenv { 150 | converter_options: ConverterOptions { 151 | project_path: PathBuf::from(project_path), 152 | dry_run: true, 153 | skip_lock: true, 154 | skip_uv_checks: false, 155 | ignore_locked_versions: true, 156 | replace_project_section: false, 157 | keep_old_metadata: false, 158 | dependency_groups_strategy: DependencyGroupsStrategy::SetDefaultGroups, 159 | }, 160 | }; 161 | 162 | insta::assert_snapshot!(pipenv.build_uv_pyproject(), @r###" 163 | [project] 164 | name = "" 165 | version = "0.0.1" 166 | requires-python = "==3.13.1" 167 | 168 | [tool.uv] 169 | package = false 170 | "###); 171 | } 172 | 173 | #[test] 174 | fn test_perform_migration_empty_requires() { 175 | let tmp_dir = tempdir().unwrap(); 176 | let project_path = tmp_dir.path(); 177 | 178 | let pipfile_content = "[requires]"; 179 | 180 | let mut pipfile_file = File::create(project_path.join("Pipfile")).unwrap(); 181 | pipfile_file.write_all(pipfile_content.as_bytes()).unwrap(); 182 | 183 | let pipenv = Pipenv { 184 | converter_options: ConverterOptions { 185 | project_path: PathBuf::from(project_path), 186 | dry_run: true, 187 | skip_lock: true, 188 | skip_uv_checks: false, 189 | ignore_locked_versions: true, 190 | replace_project_section: false, 191 | keep_old_metadata: false, 192 | dependency_groups_strategy: DependencyGroupsStrategy::SetDefaultGroups, 193 | }, 194 | }; 195 | 196 | insta::assert_snapshot!(pipenv.build_uv_pyproject(), @r###" 197 | [project] 198 | name = "" 199 | version = "0.0.1" 200 | 201 | [tool.uv] 202 | package = false 203 | "###); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/converters/pipenv/project.rs: -------------------------------------------------------------------------------- 1 | use crate::schema::pipenv::Requires; 2 | 3 | pub fn get_requires_python(pipenv_requires: Option) -> Option { 4 | let pipenv_requires = pipenv_requires?; 5 | 6 | if let Some(python_version) = pipenv_requires.python_version { 7 | return Some(format!("~={python_version}")); 8 | } 9 | 10 | if let Some(python_full_version) = pipenv_requires.python_full_version { 11 | return Some(format!("=={python_full_version}")); 12 | } 13 | 14 | None 15 | } 16 | -------------------------------------------------------------------------------- /src/converters/pipenv/sources.rs: -------------------------------------------------------------------------------- 1 | use crate::schema::pipenv::Source; 2 | use crate::schema::uv::Index; 3 | 4 | pub fn get_indexes(pipenv_sources: Option>) -> Option> { 5 | Some( 6 | pipenv_sources? 7 | .iter() 8 | .map(|source| Index { 9 | name: source.name.to_string(), 10 | url: Some(source.url.to_string()), 11 | // https://pipenv.pypa.io/en/stable/indexes.html#index-restricted-packages 12 | explicit: (source.name.to_lowercase() != "pypi").then_some(true), 13 | ..Default::default() 14 | }) 15 | .collect(), 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/converters/poetry/build_backend.rs: -------------------------------------------------------------------------------- 1 | use crate::schema::hatch::{Build, BuildTarget, Hatch}; 2 | use crate::schema::poetry::{Format, Include, Package}; 3 | use crate::schema::pyproject::BuildSystem; 4 | use crate::schema::utils::SingleOrVec; 5 | use indexmap::IndexMap; 6 | use std::path::{MAIN_SEPARATOR, Path, PathBuf}; 7 | 8 | type HatchTargetsIncludeAndSource = ( 9 | Option>, 10 | Option>, 11 | Option>, 12 | ); 13 | 14 | pub fn get_new_build_system(build_system: Option) -> Option { 15 | if build_system?.build_backend? == "poetry.core.masonry.api" { 16 | return Some(BuildSystem { 17 | requires: vec!["hatchling".to_string()], 18 | build_backend: Some("hatchling.build".to_string()), 19 | }); 20 | } 21 | None 22 | } 23 | 24 | /// Construct hatch package metadata () from Poetry 25 | /// `packages` () and `include`/`exclude` 26 | /// (). 27 | /// 28 | /// Poetry `packages` and `include` are converted to hatch `include`. 29 | /// 30 | /// If a pattern in `packages` uses `to`, an entry is populated in `sources` under hatch `wheel` 31 | /// target to rewrite the path the same way as Poetry does in wheels. Note that although Poetry's 32 | /// documentation does not specify it, `to` only rewrites paths in wheels, and not sdist, so we only 33 | /// apply path rewriting in `wheel` target. 34 | /// 35 | /// Poetry `exclude` is converted as is to hatch `exclude`. 36 | /// 37 | pub fn get_hatch( 38 | packages: Option<&Vec>, 39 | include: Option<&Vec>, 40 | exclude: Option<&Vec>, 41 | ) -> Option { 42 | let mut targets = IndexMap::new(); 43 | let (sdist_include, wheel_include, wheel_sources) = get_hatch_include(packages, include); 44 | 45 | let sdist_target = BuildTarget { 46 | include: sdist_include, 47 | exclude: exclude.cloned(), 48 | sources: None, 49 | }; 50 | let wheel_target = BuildTarget { 51 | include: wheel_include, 52 | exclude: exclude.cloned(), 53 | sources: wheel_sources, 54 | }; 55 | 56 | if sdist_target != BuildTarget::default() { 57 | targets.insert("sdist".to_string(), sdist_target); 58 | } 59 | if wheel_target != BuildTarget::default() { 60 | targets.insert("wheel".to_string(), wheel_target); 61 | } 62 | 63 | if targets.is_empty() { 64 | return None; 65 | } 66 | 67 | Some(Hatch { 68 | build: Some(Build { 69 | targets: Some(targets), 70 | }), 71 | }) 72 | } 73 | 74 | /// Inclusion behavior: 75 | /// Path rewriting behavior: 76 | fn get_hatch_include( 77 | packages: Option<&Vec>, 78 | include: Option<&Vec>, 79 | ) -> HatchTargetsIncludeAndSource { 80 | let mut sdist_include = Vec::new(); 81 | let mut wheel_include = Vec::new(); 82 | let mut wheel_sources = IndexMap::new(); 83 | 84 | // https://python-poetry.org/docs/pyproject/#packages 85 | if let Some(packages) = packages { 86 | for Package { 87 | include, 88 | format, 89 | from, 90 | to, 91 | } in packages 92 | { 93 | let include_with_from = PathBuf::from(from.as_ref().map_or("", |from| from)) 94 | .join(include) 95 | .display() 96 | .to_string() 97 | // Ensure that separator remains "/" (Windows uses "\"). 98 | .replace(MAIN_SEPARATOR, "/"); 99 | 100 | match format { 101 | None => { 102 | sdist_include.push(include_with_from.clone()); 103 | wheel_include.push(include_with_from.clone()); 104 | 105 | if let Some((from, to)) = get_hatch_source( 106 | include.clone(), 107 | include_with_from.clone(), 108 | to.as_ref(), 109 | from.as_ref(), 110 | ) { 111 | wheel_sources.insert(from, to); 112 | } 113 | } 114 | Some(SingleOrVec::Single(Format::Sdist)) => { 115 | sdist_include.push(include_with_from.clone()); 116 | } 117 | Some(SingleOrVec::Single(Format::Wheel)) => { 118 | wheel_include.push(include_with_from.clone()); 119 | 120 | if let Some((from, to)) = get_hatch_source( 121 | include.clone(), 122 | include_with_from.clone(), 123 | to.as_ref(), 124 | from.as_ref(), 125 | ) { 126 | wheel_sources.insert(from, to); 127 | } 128 | } 129 | Some(SingleOrVec::Vec(vec)) => { 130 | if vec.contains(&Format::Sdist) || vec.is_empty() { 131 | sdist_include.push(include_with_from.clone()); 132 | } 133 | if vec.contains(&Format::Wheel) || vec.is_empty() { 134 | wheel_include.push(include_with_from.clone()); 135 | 136 | if let Some((from, to)) = get_hatch_source( 137 | include.clone(), 138 | include_with_from, 139 | to.as_ref(), 140 | from.as_ref(), 141 | ) { 142 | wheel_sources.insert(from, to); 143 | } 144 | } 145 | } 146 | } 147 | } 148 | } 149 | 150 | // https://python-poetry.org/docs/pyproject/#exclude-and-include 151 | if let Some(include) = include { 152 | for inc in include { 153 | match inc { 154 | Include::String(path) | Include::Map { path, format: None } => { 155 | sdist_include.push(path.to_string()); 156 | wheel_include.push(path.to_string()); 157 | } 158 | Include::Map { 159 | path, 160 | format: Some(SingleOrVec::Vec(format)), 161 | } => match format[..] { 162 | [] | [Format::Sdist, Format::Wheel] => { 163 | sdist_include.push(path.to_string()); 164 | wheel_include.push(path.to_string()); 165 | } 166 | [Format::Sdist] => sdist_include.push(path.to_string()), 167 | [Format::Wheel] => wheel_include.push(path.to_string()), 168 | _ => (), 169 | }, 170 | Include::Map { 171 | path, 172 | format: Some(SingleOrVec::Single(Format::Sdist)), 173 | } => sdist_include.push(path.to_string()), 174 | Include::Map { 175 | path, 176 | format: Some(SingleOrVec::Single(Format::Wheel)), 177 | } => wheel_include.push(path.to_string()), 178 | } 179 | } 180 | } 181 | 182 | ( 183 | if sdist_include.is_empty() { 184 | None 185 | } else { 186 | Some(sdist_include) 187 | }, 188 | if wheel_include.is_empty() { 189 | None 190 | } else { 191 | Some(wheel_include) 192 | }, 193 | if wheel_sources.is_empty() { 194 | None 195 | } else { 196 | Some(wheel_sources) 197 | }, 198 | ) 199 | } 200 | 201 | /// Get hatch source, to rewrite path from a directory to another directory in the built artifact. 202 | /// 203 | fn get_hatch_source( 204 | include: String, 205 | include_with_from: String, 206 | to: Option<&String>, 207 | from: Option<&String>, 208 | ) -> Option<(String, String)> { 209 | if let Some(to) = to { 210 | return if include.contains('*') { 211 | // Hatch path rewrite behaves differently to Poetry, as rewriting is only possible on 212 | // static paths, so we build the longest path until we reach a glob for both the initial 213 | // and the path to rewrite to, to only rewrite the static part for both. 214 | let from_without_glob = extract_parent_path_from_glob(&include_with_from)?; 215 | let to_without_glob = extract_parent_path_from_glob(&include)?; 216 | 217 | Some(( 218 | from_without_glob, 219 | Path::new(to) 220 | .join(to_without_glob) 221 | .display() 222 | .to_string() 223 | // Ensure that separator remains "/" (Windows uses "\"). 224 | .replace(MAIN_SEPARATOR, "/"), 225 | )) 226 | } else { 227 | Some(( 228 | include_with_from, 229 | Path::new(to) 230 | .join(include) 231 | .display() 232 | .to_string() 233 | // Ensure that separator remains "/" (Windows uses "\"). 234 | .replace(MAIN_SEPARATOR, "/"), 235 | )) 236 | }; 237 | } 238 | 239 | if from.is_some() { 240 | return Some((include_with_from, include)); 241 | } 242 | 243 | None 244 | } 245 | 246 | /// Extract the longest path part from a path until a glob is found. 247 | fn extract_parent_path_from_glob(s: &str) -> Option { 248 | let mut parents = Vec::new(); 249 | 250 | for part in s.split('/') { 251 | if part.contains('*') { 252 | break; 253 | } 254 | parents.push(part); 255 | } 256 | 257 | if parents.is_empty() { 258 | return None; 259 | } 260 | Some(parents.join("/")) 261 | } 262 | -------------------------------------------------------------------------------- /src/converters/poetry/dependencies.rs: -------------------------------------------------------------------------------- 1 | use crate::converters::poetry::sources; 2 | use crate::converters::{DependencyGroupsAndDefaultGroups, DependencyGroupsStrategy}; 3 | use crate::schema; 4 | use crate::schema::poetry::DependencySpecification; 5 | use crate::schema::pyproject::DependencyGroupSpecification; 6 | use crate::schema::uv::{SourceContainer, SourceIndex}; 7 | use indexmap::IndexMap; 8 | use log::warn; 9 | use owo_colors::OwoColorize; 10 | use std::collections::HashSet; 11 | 12 | pub fn get( 13 | poetry_dependencies: Option<&IndexMap>, 14 | uv_source_index: &mut IndexMap, 15 | ) -> Option> { 16 | let poetry_dependencies = poetry_dependencies?; 17 | let mut dependencies: Vec = Vec::new(); 18 | 19 | for (name, specification) in poetry_dependencies { 20 | match specification { 21 | DependencySpecification::String(_) => { 22 | dependencies.push(format!("{}{}", name, specification.to_pep_508())); 23 | } 24 | DependencySpecification::Map { .. } => { 25 | let source_index = sources::get_source_index(specification); 26 | 27 | if let Some(source_index) = source_index { 28 | uv_source_index 29 | .insert(name.to_string(), SourceContainer::SourceIndex(source_index)); 30 | } 31 | 32 | dependencies.push(format!("{}{}", name, specification.to_pep_508())); 33 | } 34 | // Multiple constraints dependencies: https://python-poetry.org/docs/dependency-specification#multiple-constraints-dependencies 35 | DependencySpecification::Vec(specs) => { 36 | let mut source_indexes: Vec = Vec::new(); 37 | 38 | for spec in specs { 39 | let source_index = sources::get_source_index(spec); 40 | 41 | // When using multiple constraints and a source is set, markers apply to the 42 | // source, not the dependency. So if we find both a source and a marker, we 43 | // apply the marker to the source. 44 | if let Some(mut source_index) = source_index { 45 | if let DependencySpecification::Map { 46 | python, 47 | platform, 48 | markers, 49 | .. 50 | } = spec 51 | { 52 | if python.is_some() || platform.is_some() || markers.is_some() { 53 | source_index.marker = spec.get_marker(); 54 | } 55 | } 56 | 57 | source_indexes.push(source_index); 58 | } 59 | } 60 | 61 | // If no source was found on any of the dependency specification, we add the 62 | // different variants of the dependencies with their respective markers. Otherwise, 63 | // we add the different variants of the sources with their respective markers. 64 | if source_indexes.is_empty() { 65 | for spec in specs { 66 | dependencies.push(format!("{name}{}", spec.to_pep_508())); 67 | } 68 | } else { 69 | uv_source_index.insert( 70 | name.to_string(), 71 | SourceContainer::SourceIndexes(source_indexes), 72 | ); 73 | 74 | dependencies.push(name.to_string()); 75 | } 76 | } 77 | } 78 | } 79 | 80 | if dependencies.is_empty() { 81 | return None; 82 | } 83 | 84 | Some(dependencies) 85 | } 86 | 87 | pub fn get_optional( 88 | poetry_dependencies: &mut Option>, 89 | extras: Option>>, 90 | ) -> Option>> { 91 | let extras = extras?; 92 | let poetry_dependencies = poetry_dependencies.as_mut()?; 93 | 94 | let mut dependencies_to_remove: HashSet<&str> = HashSet::new(); 95 | 96 | let optional_dependencies: IndexMap> = extras 97 | .iter() 98 | .map(|(extra, extra_dependencies)| { 99 | ( 100 | extra.to_string(), 101 | extra_dependencies 102 | .iter() 103 | .filter_map(|dependency| { 104 | // If dependency listed in extra does not exist, warn the user. 105 | poetry_dependencies.get(dependency).map_or_else( 106 | || { 107 | warn!( 108 | "Could not find dependency \"{}\" listed in \"{}\" extra.", 109 | dependency.bold(), 110 | extra.bold() 111 | ); 112 | None 113 | }, 114 | |dependency_specification| { 115 | dependencies_to_remove.insert(dependency); 116 | Some(format!( 117 | "{}{}", 118 | dependency, 119 | dependency_specification.to_pep_508() 120 | )) 121 | }, 122 | ) 123 | }) 124 | .collect(), 125 | ) 126 | }) 127 | .collect(); 128 | 129 | if optional_dependencies.is_empty() { 130 | return None; 131 | } 132 | 133 | for dep in dependencies_to_remove { 134 | let _ = &mut poetry_dependencies.shift_remove(dep); 135 | } 136 | 137 | Some(optional_dependencies) 138 | } 139 | 140 | pub fn get_dependency_groups_and_default_groups( 141 | poetry: &schema::poetry::Poetry, 142 | uv_source_index: &mut IndexMap, 143 | dependency_groups_strategy: DependencyGroupsStrategy, 144 | ) -> DependencyGroupsAndDefaultGroups { 145 | let mut dependency_groups: IndexMap> = 146 | IndexMap::new(); 147 | let mut default_groups: Vec = Vec::new(); 148 | 149 | // Add dependencies from legacy `[poetry.dev-dependencies]` into `dev` dependency group. 150 | if let Some(dev_dependencies) = &poetry.dev_dependencies { 151 | dependency_groups.insert( 152 | "dev".to_string(), 153 | get(Some(dev_dependencies), uv_source_index) 154 | .unwrap_or_default() 155 | .into_iter() 156 | .map(DependencyGroupSpecification::String) 157 | .collect(), 158 | ); 159 | } 160 | 161 | // Add dependencies from `[poetry.group..dependencies]` into `` dependency group, 162 | // unless `MergeIntoDev` strategy is used, in which case we add them into `dev` dependency 163 | // group. 164 | if let Some(poetry_group) = &poetry.group { 165 | for (group, dependency_group) in poetry_group { 166 | dependency_groups 167 | .entry(match dependency_groups_strategy { 168 | DependencyGroupsStrategy::MergeIntoDev => "dev".to_string(), 169 | _ => group.to_string(), 170 | }) 171 | .or_default() 172 | .extend( 173 | get(Some(&dependency_group.dependencies), uv_source_index) 174 | .unwrap_or_default() 175 | .into_iter() 176 | .map(DependencyGroupSpecification::String), 177 | ); 178 | } 179 | 180 | match dependency_groups_strategy { 181 | // When using `SetDefaultGroups` strategy, all dependency groups are referenced in 182 | // `default-groups` under `[tool.uv]` section. If we only have `dev` dependency group, 183 | // do not set `default-groups`, as this is already uv's default. 184 | DependencyGroupsStrategy::SetDefaultGroups => { 185 | if !dependency_groups.keys().eq(["dev"]) { 186 | default_groups.extend(dependency_groups.keys().map(ToString::to_string)); 187 | } 188 | } 189 | // When using `IncludeInDev` strategy, dependency groups (except `dev` one) are 190 | // referenced from `dev` dependency group with `{ include = "" }`. 191 | DependencyGroupsStrategy::IncludeInDev => { 192 | dependency_groups 193 | .entry("dev".to_string()) 194 | .or_default() 195 | .extend(poetry_group.keys().filter(|&k| k != "dev").map(|g| { 196 | DependencyGroupSpecification::Map { 197 | include: Some(g.to_string()), 198 | } 199 | })); 200 | } 201 | _ => (), 202 | } 203 | } 204 | 205 | if dependency_groups.is_empty() { 206 | return (None, None); 207 | } 208 | 209 | ( 210 | Some(dependency_groups), 211 | if default_groups.is_empty() { 212 | None 213 | } else { 214 | Some(default_groups) 215 | }, 216 | ) 217 | } 218 | -------------------------------------------------------------------------------- /src/converters/poetry/mod.rs: -------------------------------------------------------------------------------- 1 | mod build_backend; 2 | mod dependencies; 3 | mod project; 4 | mod sources; 5 | pub mod version; 6 | 7 | use crate::converters::Converter; 8 | use crate::converters::ConverterOptions; 9 | use crate::converters::poetry::build_backend::get_hatch; 10 | use crate::converters::pyproject_updater::PyprojectUpdater; 11 | use crate::schema::pep_621::{License, Project}; 12 | use crate::schema::poetry::PoetryLock; 13 | use crate::schema::pyproject::PyProject; 14 | use crate::schema::uv::{SourceContainer, Uv}; 15 | use crate::toml::PyprojectPrettyFormatter; 16 | use indexmap::IndexMap; 17 | use log::warn; 18 | use owo_colors::OwoColorize; 19 | use std::fs; 20 | use toml_edit::DocumentMut; 21 | use toml_edit::visit_mut::VisitMut; 22 | 23 | #[derive(Debug, PartialEq, Eq)] 24 | pub struct Poetry { 25 | pub converter_options: ConverterOptions, 26 | } 27 | 28 | impl Converter for Poetry { 29 | fn build_uv_pyproject(&self) -> String { 30 | let pyproject_toml_content = 31 | fs::read_to_string(self.get_project_path().join("pyproject.toml")).unwrap_or_default(); 32 | let pyproject: PyProject = toml::from_str(pyproject_toml_content.as_str()).unwrap(); 33 | 34 | let poetry = pyproject.tool.unwrap().poetry.unwrap(); 35 | 36 | let mut uv_source_index: IndexMap = IndexMap::new(); 37 | let (dependency_groups, uv_default_groups) = 38 | dependencies::get_dependency_groups_and_default_groups( 39 | &poetry, 40 | &mut uv_source_index, 41 | self.get_dependency_groups_strategy(), 42 | ); 43 | let mut poetry_dependencies = poetry.dependencies; 44 | 45 | let python_specification = poetry_dependencies 46 | .as_mut() 47 | .and_then(|dependencies| dependencies.shift_remove("python")); 48 | 49 | let optional_dependencies = 50 | dependencies::get_optional(&mut poetry_dependencies, poetry.extras); 51 | 52 | let mut poetry_plugins = poetry.plugins; 53 | let scripts_from_plugins = poetry_plugins 54 | .as_mut() 55 | .and_then(|plugins| plugins.shift_remove("console_scripts")); 56 | let gui_scripts = poetry_plugins 57 | .as_mut() 58 | .and_then(|plugins| plugins.shift_remove("gui_scripts")); 59 | 60 | let project = Project { 61 | // "name" is required by uv. 62 | name: Some(poetry.name.unwrap_or_default()), 63 | // "version" is required by uv. 64 | version: Some(poetry.version.unwrap_or_else(|| "0.0.1".to_string())), 65 | description: poetry.description, 66 | authors: project::get_authors(poetry.authors), 67 | requires_python: python_specification.map(|p| p.to_pep_508()), 68 | readme: project::get_readme(poetry.readme), 69 | license: poetry.license.map(License::String), 70 | maintainers: project::get_authors(poetry.maintainers), 71 | keywords: poetry.keywords, 72 | classifiers: poetry.classifiers, 73 | dependencies: dependencies::get(poetry_dependencies.as_ref(), &mut uv_source_index), 74 | optional_dependencies, 75 | urls: project::get_urls( 76 | poetry.urls, 77 | poetry.homepage, 78 | poetry.repository, 79 | poetry.documentation, 80 | ), 81 | scripts: project::get_scripts(poetry.scripts, scripts_from_plugins), 82 | gui_scripts, 83 | entry_points: poetry_plugins, 84 | ..Default::default() 85 | }; 86 | 87 | let uv = Uv { 88 | package: poetry.package_mode, 89 | index: sources::get_indexes(poetry.source), 90 | sources: if uv_source_index.is_empty() { 91 | None 92 | } else { 93 | Some(uv_source_index) 94 | }, 95 | default_groups: uv_default_groups, 96 | constraint_dependencies: self.get_constraint_dependencies(), 97 | }; 98 | 99 | let hatch = get_hatch( 100 | poetry.packages.as_ref(), 101 | poetry.include.as_ref(), 102 | poetry.exclude.as_ref(), 103 | ); 104 | 105 | let mut updated_pyproject = pyproject_toml_content.parse::().unwrap(); 106 | let mut pyproject_updater = PyprojectUpdater { 107 | pyproject: &mut updated_pyproject, 108 | }; 109 | 110 | pyproject_updater.insert_build_system( 111 | build_backend::get_new_build_system(pyproject.build_system).as_ref(), 112 | ); 113 | pyproject_updater.insert_pep_621(&self.build_project(pyproject.project, project)); 114 | pyproject_updater.insert_dependency_groups(dependency_groups.as_ref()); 115 | pyproject_updater.insert_uv(&uv); 116 | pyproject_updater.insert_hatch(hatch.as_ref()); 117 | 118 | if !self.keep_old_metadata() { 119 | remove_pyproject_poetry_section(&mut updated_pyproject); 120 | } 121 | 122 | let mut visitor = PyprojectPrettyFormatter::default(); 123 | visitor.visit_document_mut(&mut updated_pyproject); 124 | 125 | updated_pyproject.to_string() 126 | } 127 | 128 | fn get_package_manager_name(&self) -> String { 129 | "Poetry".to_string() 130 | } 131 | 132 | fn get_converter_options(&self) -> &ConverterOptions { 133 | &self.converter_options 134 | } 135 | 136 | fn get_migrated_files_to_delete(&self) -> Vec { 137 | vec!["poetry.lock".to_string(), "poetry.toml".to_string()] 138 | } 139 | 140 | fn get_constraint_dependencies(&self) -> Option> { 141 | let poetry_lock_path = self.get_project_path().join("poetry.lock"); 142 | 143 | if self.is_dry_run() || !self.respect_locked_versions() || !poetry_lock_path.exists() { 144 | return None; 145 | } 146 | 147 | let poetry_lock_content = fs::read_to_string(poetry_lock_path).unwrap(); 148 | let Ok(poetry_lock) = toml::from_str::(poetry_lock_content.as_str()) else { 149 | warn!( 150 | "Could not parse \"{}\", dependencies will not be kept to their current locked versions.", 151 | "poetry.lock".bold() 152 | ); 153 | return None; 154 | }; 155 | 156 | let constraint_dependencies: Vec = poetry_lock 157 | .package 158 | .unwrap_or_default() 159 | .iter() 160 | .map(|p| format!("{}=={}", p.name, p.version)) 161 | .collect(); 162 | 163 | if constraint_dependencies.is_empty() { 164 | None 165 | } else { 166 | Some(constraint_dependencies) 167 | } 168 | } 169 | } 170 | 171 | fn remove_pyproject_poetry_section(pyproject: &mut DocumentMut) { 172 | pyproject 173 | .get_mut("tool") 174 | .unwrap() 175 | .as_table_mut() 176 | .unwrap() 177 | .remove("poetry"); 178 | } 179 | 180 | #[cfg(test)] 181 | mod tests { 182 | use super::*; 183 | use crate::converters::DependencyGroupsStrategy; 184 | use std::fs::File; 185 | use std::io::Write; 186 | use std::path::PathBuf; 187 | use tempfile::tempdir; 188 | 189 | #[test] 190 | fn test_perform_migration_multiple_readmes() { 191 | let tmp_dir = tempdir().unwrap(); 192 | let project_path = tmp_dir.path(); 193 | 194 | let pyproject_content = r#" 195 | [tool.poetry] 196 | readme = ["README1.md", "README2.md"] 197 | "#; 198 | 199 | let mut pyproject_file = File::create(project_path.join("pyproject.toml")).unwrap(); 200 | pyproject_file 201 | .write_all(pyproject_content.as_bytes()) 202 | .unwrap(); 203 | 204 | let poetry = Poetry { 205 | converter_options: ConverterOptions { 206 | project_path: PathBuf::from(project_path), 207 | dry_run: true, 208 | skip_lock: true, 209 | skip_uv_checks: false, 210 | ignore_locked_versions: true, 211 | replace_project_section: false, 212 | keep_old_metadata: false, 213 | dependency_groups_strategy: DependencyGroupsStrategy::SetDefaultGroups, 214 | }, 215 | }; 216 | 217 | insta::assert_snapshot!(poetry.build_uv_pyproject(), @r###" 218 | [project] 219 | name = "" 220 | version = "0.0.1" 221 | readme = "README1.md" 222 | "###); 223 | } 224 | 225 | #[test] 226 | fn test_perform_migration_license_text() { 227 | let tmp_dir = tempdir().unwrap(); 228 | let project_path = tmp_dir.path(); 229 | 230 | let pyproject_content = r#" 231 | [project] 232 | license = { text = "MIT" } 233 | 234 | [tool.poetry.dependencies] 235 | python = "^3.12" 236 | "#; 237 | 238 | let mut pyproject_file = File::create(project_path.join("pyproject.toml")).unwrap(); 239 | pyproject_file 240 | .write_all(pyproject_content.as_bytes()) 241 | .unwrap(); 242 | 243 | let poetry = Poetry { 244 | converter_options: ConverterOptions { 245 | project_path: PathBuf::from(project_path), 246 | dry_run: true, 247 | skip_lock: true, 248 | skip_uv_checks: false, 249 | ignore_locked_versions: true, 250 | replace_project_section: false, 251 | keep_old_metadata: false, 252 | dependency_groups_strategy: DependencyGroupsStrategy::SetDefaultGroups, 253 | }, 254 | }; 255 | 256 | insta::assert_snapshot!(poetry.build_uv_pyproject(), @r###" 257 | [project] 258 | name = "" 259 | version = "0.0.1" 260 | requires-python = "~=3.12" 261 | license = { text = "MIT" } 262 | "###); 263 | } 264 | 265 | #[test] 266 | fn test_perform_migration_license_file() { 267 | let tmp_dir = tempdir().unwrap(); 268 | let project_path = tmp_dir.path(); 269 | 270 | let pyproject_content = r#" 271 | [project] 272 | license = { file = "LICENSE" } 273 | 274 | [tool.poetry.dependencies] 275 | python = "^3.12" 276 | "#; 277 | 278 | let mut pyproject_file = File::create(project_path.join("pyproject.toml")).unwrap(); 279 | pyproject_file 280 | .write_all(pyproject_content.as_bytes()) 281 | .unwrap(); 282 | 283 | let poetry = Poetry { 284 | converter_options: ConverterOptions { 285 | project_path: PathBuf::from(project_path), 286 | dry_run: true, 287 | skip_lock: true, 288 | skip_uv_checks: false, 289 | ignore_locked_versions: true, 290 | replace_project_section: false, 291 | keep_old_metadata: false, 292 | dependency_groups_strategy: DependencyGroupsStrategy::SetDefaultGroups, 293 | }, 294 | }; 295 | 296 | insta::assert_snapshot!(poetry.build_uv_pyproject(), @r###" 297 | [project] 298 | name = "" 299 | version = "0.0.1" 300 | requires-python = "~=3.12" 301 | license = { file = "LICENSE" } 302 | "###); 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /src/converters/poetry/project.rs: -------------------------------------------------------------------------------- 1 | use crate::schema::pep_621::AuthorOrMaintainer; 2 | use crate::schema::poetry::Script; 3 | use crate::schema::utils::SingleOrVec; 4 | use indexmap::IndexMap; 5 | use log::warn; 6 | use regex::Regex; 7 | use std::sync::LazyLock; 8 | 9 | static AUTHOR_REGEX: LazyLock = 10 | LazyLock::new(|| Regex::new(r"^(?[^<>]+)(?: <(?.+?)>)?$").unwrap()); 11 | 12 | pub fn get_readme(poetry_readme: Option>) -> Option { 13 | poetry_readme.map(|readme| match readme { 14 | SingleOrVec::Single(readme) => readme, 15 | SingleOrVec::Vec(readmes) => { 16 | warn!("Found multiple readme files ({}). PEP 621 only supports setting one, so only the first one was added.", readmes.join(", ")); 17 | readmes[0].clone() 18 | } 19 | }) 20 | } 21 | 22 | pub fn get_authors(authors: Option>) -> Option> { 23 | Some( 24 | authors? 25 | .iter() 26 | .map(|p| { 27 | let captures = AUTHOR_REGEX.captures(p).unwrap(); 28 | 29 | AuthorOrMaintainer { 30 | name: captures.name("name").map(|m| m.as_str().into()), 31 | email: captures.name("email").map(|m| m.as_str().into()), 32 | } 33 | }) 34 | .collect(), 35 | ) 36 | } 37 | 38 | pub fn get_urls( 39 | poetry_urls: Option>, 40 | homepage: Option, 41 | repository: Option, 42 | documentation: Option, 43 | ) -> Option> { 44 | let mut urls: IndexMap = IndexMap::new(); 45 | 46 | if let Some(homepage) = homepage { 47 | urls.insert("Homepage".to_string(), homepage); 48 | } 49 | 50 | if let Some(repository) = repository { 51 | urls.insert("Repository".to_string(), repository); 52 | } 53 | 54 | if let Some(documentation) = documentation { 55 | urls.insert("Documentation".to_string(), documentation); 56 | } 57 | 58 | // URLs defined under `[tool.poetry.urls]` override whatever is set in `repository` or 59 | // `documentation` if there is a case-sensitive match. This is not the case for `homepage`, but 60 | // this is probably not an edge case worth handling. 61 | if let Some(poetry_urls) = poetry_urls { 62 | urls.extend(poetry_urls); 63 | } 64 | 65 | if urls.is_empty() { 66 | return None; 67 | } 68 | 69 | Some(urls) 70 | } 71 | 72 | pub fn get_scripts( 73 | poetry_scripts: Option>, 74 | scripts_from_plugins: Option>, 75 | ) -> Option> { 76 | let mut scripts: IndexMap = IndexMap::new(); 77 | 78 | if let Some(poetry_scripts) = poetry_scripts { 79 | for (name, script) in poetry_scripts { 80 | match script { 81 | Script::String(script) => { 82 | scripts.insert(name, script); 83 | } 84 | Script::Map { callable } => { 85 | if let Some(callable) = callable { 86 | scripts.insert(name, callable); 87 | } 88 | } 89 | } 90 | } 91 | } 92 | 93 | if let Some(scripts_from_plugins) = scripts_from_plugins { 94 | scripts.extend(scripts_from_plugins); 95 | } 96 | 97 | if scripts.is_empty() { 98 | return None; 99 | } 100 | Some(scripts) 101 | } 102 | -------------------------------------------------------------------------------- /src/converters/poetry/sources.rs: -------------------------------------------------------------------------------- 1 | use crate::schema::poetry::{DependencySpecification, Source, SourcePriority}; 2 | use crate::schema::uv::{Index, SourceIndex}; 3 | 4 | pub fn get_source_index(dependency_specification: &DependencySpecification) -> Option { 5 | match dependency_specification { 6 | DependencySpecification::Map { 7 | source: Some(source), 8 | .. 9 | } => Some(SourceIndex { 10 | index: Some(source.to_string()), 11 | ..Default::default() 12 | }), 13 | DependencySpecification::Map { url: Some(url), .. } => Some(SourceIndex { 14 | url: Some(url.to_string()), 15 | ..Default::default() 16 | }), 17 | DependencySpecification::Map { 18 | path: Some(path), 19 | develop, 20 | .. 21 | } => Some(SourceIndex { 22 | path: Some(path.to_string()), 23 | editable: *develop, 24 | ..Default::default() 25 | }), 26 | DependencySpecification::Map { 27 | git: Some(git), 28 | branch, 29 | rev, 30 | tag, 31 | subdirectory, 32 | .. 33 | } => Some(SourceIndex { 34 | git: Some(git.clone()), 35 | branch: branch.clone(), 36 | rev: rev.clone(), 37 | tag: tag.clone(), 38 | subdirectory: subdirectory.clone(), 39 | ..Default::default() 40 | }), 41 | _ => None, 42 | } 43 | } 44 | 45 | pub fn get_indexes(poetry_sources: Option>) -> Option> { 46 | Some( 47 | poetry_sources? 48 | .iter() 49 | .map(|source| Index { 50 | name: source.name.clone(), 51 | url: match source.name.to_lowercase().as_str() { 52 | "pypi" => Some("https://pypi.org/simple/".to_string()), 53 | _ => source.url.clone(), 54 | }, 55 | default: match source.priority { 56 | Some(SourcePriority::Default | SourcePriority::Primary) => Some(true), 57 | _ => None, 58 | }, 59 | explicit: match source.priority { 60 | Some(SourcePriority::Explicit) => Some(true), 61 | _ => None, 62 | }, 63 | }) 64 | .collect(), 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/converters/poetry/version.rs: -------------------------------------------------------------------------------- 1 | use pep440_rs::{Version, VersionSpecifiers}; 2 | use std::str::FromStr; 3 | 4 | pub enum PoetryPep440 { 5 | String(String), 6 | Compatible(Version), 7 | Matching(Version), 8 | Inclusive(Version, Version), 9 | } 10 | 11 | impl PoetryPep440 { 12 | pub fn to_python_marker(&self) -> String { 13 | let pep_440_python = VersionSpecifiers::from_str(self.to_string().as_str()).unwrap(); 14 | 15 | pep_440_python 16 | .iter() 17 | .map(|spec| format!("python_version {} '{}'", spec.operator(), spec.version())) 18 | .collect::>() 19 | .join(" and ") 20 | } 21 | 22 | /// 23 | fn from_caret(s: &str) -> Self { 24 | if let Ok(version) = Version::from_str(s) { 25 | return match version.clone().release() { 26 | [0, 0, z] => Self::Inclusive(version, Version::new([0, 0, z + 1])), 27 | [0, y] | [0, y, _, ..] => Self::Inclusive(version, Version::new([0, y + 1])), 28 | [x, _, _, ..] | [x] => Self::Inclusive(version, Version::new([x + 1])), 29 | [_, _] => Self::Compatible(version), 30 | [..] => Self::String(String::new()), 31 | }; 32 | } 33 | Self::Matching(Version::from_str(s).unwrap()) 34 | } 35 | 36 | /// 37 | fn from_tilde(s: &str) -> Self { 38 | if let Ok(version) = Version::from_str(s) { 39 | return match version.clone().release() { 40 | [_, _, _, ..] => Self::Compatible(version), 41 | [x, y] => Self::Inclusive(version, Version::new([x, &(y + 1)])), 42 | [x] => Self::Inclusive(version, Version::new([x + 1])), 43 | [..] => Self::String(String::new()), 44 | }; 45 | } 46 | Self::Matching(Version::from_str(s).unwrap()) 47 | } 48 | } 49 | 50 | #[derive(Debug, PartialEq, Eq)] 51 | pub struct ParsePep440Error; 52 | 53 | impl FromStr for PoetryPep440 { 54 | type Err = ParsePep440Error; 55 | 56 | fn from_str(s: &str) -> Result { 57 | let s = s.trim(); 58 | 59 | // While Poetry has its own specification for version specifiers, it also supports most of 60 | // the version specifiers defined by PEP 440. So if the version is a valid PEP 440 61 | // definition, we can directly use it without any transformation. 62 | if VersionSpecifiers::from_str(s).is_ok() { 63 | return Ok(Self::String(s.to_string())); 64 | } 65 | 66 | match s.split_at(1) { 67 | ("*", "") => Ok(Self::String(String::new())), 68 | ("^", version) => Ok(Self::from_caret(version.trim())), 69 | ("~", version) => Ok(Self::from_tilde(version.trim())), 70 | _ => Ok(Self::String(format!("=={s}"))), 71 | } 72 | } 73 | } 74 | 75 | impl std::fmt::Display for PoetryPep440 { 76 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 77 | let str = match &self { 78 | Self::String(s) => s.to_string(), 79 | Self::Compatible(version) => format!("~={version}"), 80 | Self::Matching(version) => format!("=={version}"), 81 | Self::Inclusive(lower, upper) => format!(">={lower},<{upper}"), 82 | }; 83 | 84 | write!(f, "{str}") 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/converters/pyproject_updater.rs: -------------------------------------------------------------------------------- 1 | use crate::schema::hatch::Hatch; 2 | use crate::schema::pep_621::Project; 3 | use crate::schema::pyproject::{BuildSystem, DependencyGroupSpecification}; 4 | use crate::schema::uv::Uv; 5 | use indexmap::IndexMap; 6 | use toml_edit::{DocumentMut, table, value}; 7 | 8 | /// Updates a `pyproject.toml` document. 9 | pub struct PyprojectUpdater<'a> { 10 | pub pyproject: &'a mut DocumentMut, 11 | } 12 | 13 | impl PyprojectUpdater<'_> { 14 | /// Adds or replaces PEP 621 data. 15 | pub fn insert_pep_621(&mut self, project: &Project) { 16 | self.pyproject["project"] = value( 17 | serde::Serialize::serialize(&project, toml_edit::ser::ValueSerializer::new()).unwrap(), 18 | ); 19 | } 20 | 21 | /// Adds or replaces dependency groups data in TOML document. 22 | pub fn insert_dependency_groups( 23 | &mut self, 24 | dependency_groups: Option<&IndexMap>>, 25 | ) { 26 | if let Some(dependency_groups) = dependency_groups { 27 | self.pyproject["dependency-groups"] = value( 28 | serde::Serialize::serialize( 29 | &dependency_groups, 30 | toml_edit::ser::ValueSerializer::new(), 31 | ) 32 | .unwrap(), 33 | ); 34 | } 35 | } 36 | 37 | /// Adds or replaces build system data. 38 | pub fn insert_build_system(&mut self, build_system: Option<&BuildSystem>) { 39 | if let Some(build_system) = build_system { 40 | self.pyproject["build-system"] = value( 41 | serde::Serialize::serialize(&build_system, toml_edit::ser::ValueSerializer::new()) 42 | .unwrap(), 43 | ); 44 | } 45 | } 46 | 47 | /// Adds or replaces uv-specific data in TOML document. 48 | pub fn insert_uv(&mut self, uv: &Uv) { 49 | if uv == &Uv::default() { 50 | return; 51 | } 52 | 53 | if !self.pyproject.contains_key("tool") { 54 | self.pyproject["tool"] = table(); 55 | } 56 | 57 | self.pyproject["tool"]["uv"] = value( 58 | serde::Serialize::serialize(&uv, toml_edit::ser::ValueSerializer::new()).unwrap(), 59 | ); 60 | } 61 | 62 | /// Adds or replaces hatch-specific data in TOML document. 63 | pub fn insert_hatch(&mut self, hatch: Option<&Hatch>) { 64 | if hatch.is_none() { 65 | return; 66 | } 67 | 68 | if !self.pyproject.contains_key("tool") { 69 | self.pyproject["tool"] = table(); 70 | } 71 | 72 | self.pyproject["tool"]["hatch"] = value( 73 | serde::Serialize::serialize(&hatch, toml_edit::ser::ValueSerializer::new()).unwrap(), 74 | ); 75 | } 76 | 77 | /// Remove `constraint-dependencies` under `[tool.uv]`, which is only needed to lock 78 | /// dependencies to specific versions in the generated lock file. 79 | pub fn remove_constraint_dependencies(&mut self) -> Option<&DocumentMut> { 80 | self.pyproject 81 | .get_mut("tool")? 82 | .as_table_mut()? 83 | .get_mut("uv")? 84 | .as_table_mut()? 85 | .remove("constraint-dependencies")?; 86 | 87 | // If `constraint-dependencies` was the only item in `[tool.uv]`, remove `[tool.uv]`. 88 | if self 89 | .pyproject 90 | .get("tool")? 91 | .as_table()? 92 | .get("uv")? 93 | .as_table()? 94 | .is_empty() 95 | { 96 | self.pyproject 97 | .get_mut("tool")? 98 | .as_table_mut()? 99 | .remove("uv")?; 100 | } 101 | 102 | Some(self.pyproject) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | mod converters; 3 | mod detector; 4 | mod logger; 5 | mod schema; 6 | mod toml; 7 | 8 | use crate::cli::cli; 9 | 10 | pub fn main() { 11 | cli(); 12 | } 13 | -------------------------------------------------------------------------------- /src/logger.rs: -------------------------------------------------------------------------------- 1 | use clap_verbosity_flag::{InfoLevel, Verbosity}; 2 | use log::Level; 3 | use owo_colors::OwoColorize; 4 | use std::io::Write; 5 | 6 | pub fn configure(verbosity: Verbosity) { 7 | env_logger::Builder::new() 8 | .filter_level(verbosity.log_level_filter()) 9 | .format(|buf, record| match record.level() { 10 | Level::Error => writeln!(buf, "{}: {}", "error".red().bold(), record.args()), 11 | Level::Warn => writeln!(buf, "{}: {}", "warning".yellow().bold(), record.args()), 12 | Level::Debug => writeln!(buf, "{}: {}", "debug".blue().bold(), record.args()), 13 | _ => writeln!(buf, "{}", record.args()), 14 | }) 15 | .init(); 16 | } 17 | -------------------------------------------------------------------------------- /src/schema/hatch.rs: -------------------------------------------------------------------------------- 1 | use indexmap::IndexMap; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Deserialize, Serialize)] 5 | pub struct Hatch { 6 | pub build: Option, 7 | } 8 | 9 | #[derive(Default, Eq, PartialEq, Deserialize, Serialize)] 10 | pub struct Build { 11 | pub targets: Option>, 12 | } 13 | 14 | #[derive(Default, Deserialize, Serialize, Eq, PartialEq)] 15 | pub struct BuildTarget { 16 | pub include: Option>, 17 | pub exclude: Option>, 18 | pub sources: Option>, 19 | } 20 | -------------------------------------------------------------------------------- /src/schema/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod hatch; 2 | pub mod pep_621; 3 | pub mod pipenv; 4 | pub mod poetry; 5 | pub mod pyproject; 6 | pub mod utils; 7 | pub mod uv; 8 | -------------------------------------------------------------------------------- /src/schema/pep_621.rs: -------------------------------------------------------------------------------- 1 | use crate::schema::poetry::Poetry; 2 | use crate::schema::uv::Uv; 3 | use indexmap::IndexMap; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_json::Value; 6 | use std::collections::HashMap; 7 | 8 | /// 9 | #[derive(Default, Deserialize, Serialize)] 10 | pub struct Project { 11 | pub name: Option, 12 | pub version: Option, 13 | pub description: Option, 14 | pub authors: Option>, 15 | #[serde(rename = "requires-python")] 16 | pub requires_python: Option, 17 | pub readme: Option, 18 | pub license: Option, 19 | pub maintainers: Option>, 20 | pub keywords: Option>, 21 | pub classifiers: Option>, 22 | pub dependencies: Option>, 23 | #[serde(rename = "optional-dependencies")] 24 | pub optional_dependencies: Option>>, 25 | pub urls: Option>, 26 | pub scripts: Option>, 27 | #[serde(rename = "gui-scripts")] 28 | pub gui_scripts: Option>, 29 | #[serde(rename = "entry-points")] 30 | pub entry_points: Option>>, 31 | #[serde(flatten)] 32 | pub remaining_fields: HashMap, 33 | } 34 | 35 | #[derive(Deserialize, Serialize)] 36 | pub struct AuthorOrMaintainer { 37 | pub name: Option, 38 | pub email: Option, 39 | } 40 | 41 | #[derive(Deserialize, Serialize)] 42 | #[serde(untagged)] 43 | pub enum License { 44 | String(String), 45 | Map { 46 | text: Option, 47 | file: Option, 48 | }, 49 | } 50 | 51 | #[derive(Deserialize, Serialize)] 52 | pub struct Tool { 53 | pub poetry: Option, 54 | pub uv: Option, 55 | } 56 | -------------------------------------------------------------------------------- /src/schema/pipenv.rs: -------------------------------------------------------------------------------- 1 | use indexmap::IndexMap; 2 | use serde::Deserialize; 3 | use std::collections::BTreeMap; 4 | 5 | #[derive(Deserialize)] 6 | pub struct Pipfile { 7 | pub source: Option>, 8 | pub packages: Option>, 9 | #[serde(rename = "dev-packages")] 10 | pub dev_packages: Option>, 11 | pub requires: Option, 12 | /// Not used, this avoids having the section in `category_groups` below. 13 | #[allow(dead_code)] 14 | pipenv: Option, 15 | /// Not used, this avoids having the section in `category_groups` below 16 | #[allow(dead_code)] 17 | scripts: Option, 18 | /// Assume that remaining keys are category groups (). 19 | #[serde(flatten)] 20 | pub category_groups: Option>>, 21 | } 22 | 23 | #[derive(Deserialize)] 24 | #[serde(untagged)] 25 | #[allow(clippy::large_enum_variant)] 26 | pub enum DependencySpecification { 27 | String(String), 28 | Map { 29 | version: Option, 30 | extras: Option>, 31 | markers: Option, 32 | index: Option, 33 | git: Option, 34 | #[serde(rename = "ref")] 35 | ref_: Option, 36 | path: Option, 37 | editable: Option, 38 | #[serde(flatten)] 39 | keyword_markers: KeywordMarkers, 40 | }, 41 | } 42 | 43 | #[derive(Deserialize)] 44 | pub struct Source { 45 | pub name: String, 46 | pub url: String, 47 | } 48 | 49 | #[derive(Deserialize)] 50 | pub struct Requires { 51 | pub python_version: Option, 52 | pub python_full_version: Option, 53 | } 54 | 55 | /// Markers can be set as keywords: 56 | #[derive(Deserialize)] 57 | pub struct KeywordMarkers { 58 | pub os_name: Option, 59 | pub sys_platform: Option, 60 | pub platform_machine: Option, 61 | pub platform_python_implementation: Option, 62 | pub platform_release: Option, 63 | pub platform_system: Option, 64 | pub platform_version: Option, 65 | pub python_version: Option, 66 | pub python_full_version: Option, 67 | pub implementation_name: Option, 68 | pub implementation_version: Option, 69 | } 70 | 71 | #[derive(Deserialize)] 72 | pub struct PipenvLock { 73 | /// Not used, this avoids having the section in `category_groups` below. 74 | #[allow(dead_code)] 75 | #[serde(rename = "_meta")] 76 | meta: Option, 77 | #[serde(flatten)] 78 | pub category_groups: Option>>, 79 | } 80 | 81 | #[derive(Deserialize)] 82 | pub struct LockedPackage { 83 | pub version: String, 84 | } 85 | 86 | #[derive(Deserialize)] 87 | pub struct Placeholder {} 88 | -------------------------------------------------------------------------------- /src/schema/poetry.rs: -------------------------------------------------------------------------------- 1 | use crate::converters::poetry::version::PoetryPep440; 2 | use crate::schema::utils::SingleOrVec; 3 | use indexmap::IndexMap; 4 | use serde::{Deserialize, Serialize}; 5 | use std::str::FromStr; 6 | 7 | #[derive(Deserialize, Serialize)] 8 | pub struct Poetry { 9 | #[serde(rename = "package-mode")] 10 | pub package_mode: Option, 11 | pub name: Option, 12 | pub version: Option, 13 | pub description: Option, 14 | pub authors: Option>, 15 | pub license: Option, 16 | pub maintainers: Option>, 17 | pub readme: Option>, 18 | pub homepage: Option, 19 | pub repository: Option, 20 | pub documentation: Option, 21 | pub keywords: Option>, 22 | pub classifiers: Option>, 23 | pub source: Option>, 24 | pub dependencies: Option>, 25 | pub extras: Option>>, 26 | #[serde(rename = "dev-dependencies")] 27 | pub dev_dependencies: Option>, 28 | pub group: Option>, 29 | pub urls: Option>, 30 | pub scripts: Option>, 31 | pub plugins: Option>>, 32 | pub packages: Option>, 33 | pub include: Option>, 34 | pub exclude: Option>, 35 | } 36 | 37 | #[derive(Deserialize, Serialize)] 38 | pub struct DependencyGroup { 39 | pub dependencies: IndexMap, 40 | } 41 | 42 | /// Represents a package source: . 43 | #[derive(Deserialize, Serialize)] 44 | pub struct Source { 45 | pub name: String, 46 | pub url: Option, 47 | pub priority: Option, 48 | } 49 | 50 | #[derive(Deserialize, Serialize, Eq, PartialEq, Debug)] 51 | #[serde(rename_all = "lowercase")] 52 | pub enum SourcePriority { 53 | /// . 54 | Primary, 55 | /// . 56 | Supplemental, 57 | /// . 58 | Explicit, 59 | /// . 60 | Default, 61 | /// . 62 | Secondary, 63 | } 64 | 65 | /// Represents the different ways a script can be defined in Poetry. 66 | #[derive(Deserialize, Serialize)] 67 | #[serde(untagged)] 68 | pub enum Script { 69 | String(String), 70 | // Although not documented, a script can be set as a map, where `callable` is the script to run. 71 | // An `extra` field also exists, but it doesn't seem to actually do 72 | // anything (https://github.com/python-poetry/poetry/issues/6892). 73 | Map { callable: Option }, 74 | } 75 | 76 | /// Represents the different ways dependencies can be defined in Poetry. 77 | /// 78 | /// See for details. 79 | #[derive(Deserialize, Serialize)] 80 | #[serde(untagged)] 81 | #[allow(clippy::large_enum_variant)] 82 | pub enum DependencySpecification { 83 | /// Simple version constraint: . 84 | String(String), 85 | /// Complex version constraint: . 86 | Map { 87 | version: Option, 88 | extras: Option>, 89 | markers: Option, 90 | python: Option, 91 | platform: Option, 92 | source: Option, 93 | git: Option, 94 | branch: Option, 95 | rev: Option, 96 | tag: Option, 97 | subdirectory: Option, 98 | path: Option, 99 | develop: Option, 100 | url: Option, 101 | }, 102 | /// Multiple constraints dependencies: . 103 | Vec(Vec), 104 | } 105 | 106 | impl DependencySpecification { 107 | pub fn to_pep_508(&self) -> String { 108 | match self { 109 | Self::String(version) => PoetryPep440::from_str(version).unwrap().to_string(), 110 | Self::Map { 111 | version, extras, .. 112 | } => { 113 | let mut pep_508_version = String::new(); 114 | 115 | if let Some(extras) = extras { 116 | pep_508_version.push_str(format!("[{}]", extras.join(", ")).as_str()); 117 | } 118 | 119 | if let Some(version) = version { 120 | pep_508_version.push_str( 121 | PoetryPep440::from_str(version) 122 | .unwrap() 123 | .to_string() 124 | .as_str(), 125 | ); 126 | } 127 | 128 | if let Some(marker) = self.get_marker() { 129 | pep_508_version.push_str(format!(" ; {marker}").as_str()); 130 | } 131 | 132 | pep_508_version 133 | } 134 | Self::Vec(_) => String::new(), 135 | } 136 | } 137 | 138 | pub fn get_marker(&self) -> Option { 139 | let mut combined_markers: Vec = Vec::new(); 140 | 141 | if let Self::Map { 142 | python, 143 | markers, 144 | platform, 145 | .. 146 | } = self 147 | { 148 | if let Some(python) = python { 149 | combined_markers.push(PoetryPep440::from_str(python).unwrap().to_python_marker()); 150 | } 151 | 152 | if let Some(markers) = markers { 153 | combined_markers.push(markers.to_string()); 154 | } 155 | 156 | if let Some(platform) = platform { 157 | combined_markers.push(format!("sys_platform == '{platform}'")); 158 | } 159 | } 160 | 161 | if combined_markers.is_empty() { 162 | return None; 163 | } 164 | Some(combined_markers.join(" and ")) 165 | } 166 | } 167 | 168 | /// Package distribution definition . 169 | #[derive(Deserialize, Serialize)] 170 | pub struct Package { 171 | pub include: String, 172 | pub from: Option, 173 | pub to: Option, 174 | pub format: Option>, 175 | } 176 | 177 | /// Package distribution file inclusion: . 178 | #[derive(Deserialize, Serialize)] 179 | #[serde(untagged)] 180 | pub enum Include { 181 | String(String), 182 | Map { 183 | path: String, 184 | format: Option>, 185 | }, 186 | } 187 | 188 | #[derive(Deserialize, Serialize, Eq, PartialEq)] 189 | #[serde(rename_all = "lowercase")] 190 | pub enum Format { 191 | Sdist, 192 | Wheel, 193 | } 194 | 195 | #[derive(Deserialize)] 196 | pub struct PoetryLock { 197 | pub package: Option>, 198 | } 199 | 200 | #[derive(Deserialize)] 201 | pub struct LockedPackage { 202 | pub name: String, 203 | pub version: String, 204 | } 205 | -------------------------------------------------------------------------------- /src/schema/pyproject.rs: -------------------------------------------------------------------------------- 1 | use crate::schema::pep_621::Project; 2 | use crate::schema::pep_621::Tool; 3 | use indexmap::IndexMap; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Deserialize, Serialize)] 7 | pub struct PyProject { 8 | #[serde(rename = "build-system")] 9 | pub build_system: Option, 10 | pub project: Option, 11 | /// 12 | #[serde(rename = "dependency-groups")] 13 | pub dependency_groups: Option>>, 14 | pub tool: Option, 15 | } 16 | 17 | #[derive(Deserialize, Serialize)] 18 | #[serde(untagged)] 19 | pub enum DependencyGroupSpecification { 20 | String(String), 21 | Map { include: Option }, 22 | } 23 | 24 | #[derive(Deserialize, Serialize)] 25 | pub struct BuildSystem { 26 | pub requires: Vec, 27 | #[serde(rename = "build-backend")] 28 | pub build_backend: Option, 29 | } 30 | -------------------------------------------------------------------------------- /src/schema/utils.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Serialize)] 4 | #[serde(untagged)] 5 | pub enum SingleOrVec { 6 | Single(T), 7 | Vec(Vec), 8 | } 9 | -------------------------------------------------------------------------------- /src/schema/uv.rs: -------------------------------------------------------------------------------- 1 | use indexmap::IndexMap; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Default, Deserialize, Serialize, Eq, PartialEq)] 5 | pub struct Uv { 6 | pub package: Option, 7 | /// 8 | pub index: Option>, 9 | /// 10 | pub sources: Option>, 11 | /// 12 | #[serde(rename = "default-groups")] 13 | pub default_groups: Option>, 14 | #[serde(rename = "constraint-dependencies")] 15 | pub constraint_dependencies: Option>, 16 | } 17 | 18 | #[derive(Default, Deserialize, Serialize, Eq, PartialEq)] 19 | pub struct Index { 20 | pub name: String, 21 | pub url: Option, 22 | pub default: Option, 23 | pub explicit: Option, 24 | } 25 | 26 | #[derive(Default, Deserialize, Serialize, Eq, PartialEq)] 27 | pub struct SourceIndex { 28 | pub index: Option, 29 | pub path: Option, 30 | pub editable: Option, 31 | pub git: Option, 32 | pub tag: Option, 33 | pub branch: Option, 34 | pub rev: Option, 35 | pub subdirectory: Option, 36 | pub url: Option, 37 | pub marker: Option, 38 | } 39 | 40 | #[derive(Deserialize, Serialize, Eq, PartialEq)] 41 | #[serde(untagged)] 42 | pub enum SourceContainer { 43 | SourceIndex(SourceIndex), 44 | SourceIndexes(Vec), 45 | } 46 | -------------------------------------------------------------------------------- /src/toml.rs: -------------------------------------------------------------------------------- 1 | use toml_edit::visit_mut::{ 2 | VisitMut, visit_array_mut, visit_item_mut, visit_table_like_kv_mut, visit_table_mut, 3 | }; 4 | use toml_edit::{Array, InlineTable, Item, KeyMut, Value}; 5 | 6 | #[derive(Default)] 7 | pub struct PyprojectPrettyFormatter { 8 | pub parent_keys: Vec, 9 | } 10 | 11 | /// Prettifies Pyproject TOML based on usual conventions in the ecosystem. 12 | impl VisitMut for PyprojectPrettyFormatter { 13 | fn visit_item_mut(&mut self, node: &mut Item) { 14 | let parent_keys: Vec<&str> = self.parent_keys.iter().map(AsRef::as_ref).collect(); 15 | 16 | // Uv indexes are usually represented as array of tables (https://docs.astral.sh/uv/configuration/indexes/). 17 | if let ["tool", "uv", "index"] = parent_keys.as_slice() { 18 | let new_node = std::mem::take(node); 19 | let new_node = new_node 20 | .into_array_of_tables() 21 | .map_or_else(|i| i, Item::ArrayOfTables); 22 | 23 | *node = new_node; 24 | } 25 | 26 | visit_item_mut(self, node); 27 | } 28 | 29 | fn visit_table_mut(&mut self, node: &mut toml_edit::Table) { 30 | node.decor_mut().clear(); 31 | 32 | if !node.is_empty() { 33 | node.set_implicit(true); 34 | } 35 | 36 | visit_table_mut(self, node); 37 | } 38 | 39 | fn visit_table_like_kv_mut(&mut self, mut key: KeyMut<'_>, node: &mut Item) { 40 | self.parent_keys.push(key.to_string()); 41 | 42 | // Convert some inline tables into tables, when those tables are usually represented as 43 | // plain tables in the ecosystem. 44 | if let Item::Value(Value::InlineTable(inline_table)) = node { 45 | let parent_keys: Vec<&str> = self.parent_keys.iter().map(AsRef::as_ref).collect(); 46 | 47 | if matches!( 48 | parent_keys.as_slice(), 49 | ["build-system" | "project" | "dependency-groups"] 50 | | [ 51 | "project", 52 | "urls" 53 | | "optional-dependencies" 54 | | "scripts" 55 | | "gui-scripts" 56 | | "entry-points" 57 | ] 58 | | ["project", "entry-points", _] 59 | | ["tool", "uv"] 60 | | ["tool", "uv", "sources"] 61 | | ["tool", "hatch", ..] 62 | ) { 63 | let position = match parent_keys.as_slice() { 64 | ["project"] => Some(0), 65 | ["dependency-groups"] => Some(1), 66 | ["tool", "uv"] => Some(2), 67 | ["tool", "hatch"] => Some(3), 68 | _ => None, 69 | }; 70 | 71 | let inline_table = std::mem::replace(inline_table, InlineTable::new()); 72 | let mut table = inline_table.into_table(); 73 | 74 | if let Some(position) = position { 75 | table.set_position(position); 76 | } 77 | 78 | key.fmt(); 79 | *node = Item::Table(table); 80 | } 81 | } 82 | 83 | visit_table_like_kv_mut(self, key, node); 84 | 85 | self.parent_keys.pop(); 86 | } 87 | 88 | fn visit_array_mut(&mut self, node: &mut Array) { 89 | visit_array_mut(self, node); 90 | 91 | let parent_keys: Vec<&str> = self.parent_keys.iter().map(AsRef::as_ref).collect(); 92 | 93 | // It is common to have each array item on its own line if the array contains more than 2 94 | // items, so this applies this format on sections that were added. Targeting specific 95 | // sections ensures that unrelated sections are left intact. 96 | if matches!( 97 | parent_keys.as_slice(), 98 | ["project" | "dependency-groups", ..] | ["tool", "uv" | "hatch", ..] 99 | ) && node.len() >= 2 100 | { 101 | for item in node.iter_mut() { 102 | item.decor_mut().set_prefix("\n "); 103 | } 104 | 105 | node.set_trailing_comma(true); 106 | node.set_trailing("\n"); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | use insta_cmd::get_cargo_bin; 2 | use serde::Deserialize; 3 | use std::process::Command; 4 | 5 | macro_rules! apply_lock_filters { 6 | {} => { 7 | let mut settings = insta::Settings::clone_current(); 8 | settings.add_filter(r"Using .+", "Using [PYTHON_INTERPRETER]"); 9 | settings.add_filter(r"Defaulting to `\S+`", "Defaulting to `[PYTHON_VERSION]`"); 10 | settings.add_filter(r"Resolved \d+ packages in \S+", "Resolved [PACKAGES] packages in [TIME]"); 11 | settings.add_filter(r"Updated https://github.com/encode/uvicorn (\S+)", "Updated https://github.com/encode/uvicorn ([SHA1])"); 12 | let _bound = settings.bind_to_scope(); 13 | } 14 | } 15 | 16 | pub(crate) use apply_lock_filters; 17 | 18 | #[derive(Deserialize, Eq, PartialEq, Debug)] 19 | pub struct UvLock { 20 | pub package: Option>, 21 | } 22 | 23 | #[derive(Deserialize, Eq, PartialEq, Debug)] 24 | pub struct LockedPackage { 25 | pub name: String, 26 | pub version: String, 27 | } 28 | 29 | pub fn cli() -> Command { 30 | Command::new(get_cargo_bin("migrate-to-uv")) 31 | } 32 | -------------------------------------------------------------------------------- /tests/fixtures/pip/existing_project/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "foobar" 3 | version = "1.0.0" 4 | requires-python = ">=3.13" 5 | -------------------------------------------------------------------------------- /tests/fixtures/pip/existing_project/requirements.txt: -------------------------------------------------------------------------------- 1 | # A comment 2 | arrow==1.3.0 3 | httpx[cli]==0.28.1 4 | uvicorn @ git+https://github.com/encode/uvicorn 5 | -------------------------------------------------------------------------------- /tests/fixtures/pip/full/constraints-2.txt: -------------------------------------------------------------------------------- 1 | zstandard==0.23.0 2 | -------------------------------------------------------------------------------- /tests/fixtures/pip/full/constraints.txt: -------------------------------------------------------------------------------- 1 | h11==0.14.0 2 | httpcore==1.0.7 3 | -------------------------------------------------------------------------------- /tests/fixtures/pip/full/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # This will be ignored 2 | -r requirements-typing.txt 3 | 4 | # A comment 5 | pytest==8.3.4 6 | ruff==0.8.4 7 | -------------------------------------------------------------------------------- /tests/fixtures/pip/full/requirements-typing.txt: -------------------------------------------------------------------------------- 1 | # A comment 2 | mypy==1.14.1 3 | types-jsonschema==4.23.0.20241208 4 | -------------------------------------------------------------------------------- /tests/fixtures/pip/full/requirements.txt: -------------------------------------------------------------------------------- 1 | # This will be ignored 2 | -c constraints.txt 3 | -cconstraints2.txt 4 | 5 | # A comment 6 | ## Another comment 7 | arrow==1.3.0 8 | httpx [ cli ] == 0.28.1 9 | uvicorn @ git+https://github.com/encode/uvicorn 10 | 11 | # Inline comments are not ignored, making parsing fail (https://github.com/mkniewallner/migrate-to-uv/issues/102) 12 | requests==2.32.3 # Inline comment 13 | 14 | # Non-PEP 508 compliant 15 | file:bar 16 | file:./bar 17 | -e file:bar 18 | -e file:./bar 19 | git+https://github.com/psf/requests 20 | git+https://github.com/psf/requests#egg=requests 21 | -e git+https://github.com/psf/requests#egg=requests 22 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/existing_project/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "foobar" 3 | version = "1.0.0" 4 | requires-python = ">=3.13" 5 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/existing_project/requirements.in: -------------------------------------------------------------------------------- 1 | arrow>=1.2.3 2 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/existing_project/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | arrow==1.2.3 8 | # via -r requirements.in 9 | python-dateutil==2.7.0 10 | # via arrow 11 | six==1.15.0 12 | # via python-dateutil 13 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/full/requirements-dev.in: -------------------------------------------------------------------------------- 1 | pytest>=8.3.4 2 | ruff==0.8.4 3 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/full/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile requirements-dev.in 6 | # 7 | iniconfig==2.0.0 8 | # via pytest 9 | packaging==24.2 10 | # via pytest 11 | pluggy==1.5.0 12 | # via pytest 13 | pytest==8.3.4 14 | # via -r requirements-dev.in 15 | ruff==0.8.4 16 | # via -r requirements-dev.in 17 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/full/requirements-typing.in: -------------------------------------------------------------------------------- 1 | mypy==1.14.1 2 | types-jsonschema==4.23.0.20241208 3 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/full/requirements-typing.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile requirements-typing.in 6 | # 7 | attrs==24.3.0 8 | # via referencing 9 | mypy==1.14.1 10 | # via -r requirements-typing.in 11 | mypy-extensions==1.0.0 12 | # via mypy 13 | referencing==0.35.1 14 | # via types-jsonschema 15 | rpds-py==0.22.3 16 | # via referencing 17 | types-jsonschema==4.23.0.20241208 18 | # via -r requirements-typing.in 19 | typing-extensions==4.12.2 20 | # via mypy 21 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/full/requirements.in: -------------------------------------------------------------------------------- 1 | arrow 2 | httpx[cli,zstd]==0.28.1 3 | uvicorn @ git+https://github.com/encode/uvicorn 4 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/full/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | anyio==4.7.0 8 | # via httpx 9 | arrow==1.3.0 10 | # via -r requirements.in 11 | certifi==2024.12.14 12 | # via 13 | # httpcore 14 | # httpx 15 | click==8.1.8 16 | # via 17 | # httpx 18 | # uvicorn 19 | h11==0.14.0 20 | # via 21 | # httpcore 22 | # uvicorn 23 | httpcore==1.0.7 24 | # via httpx 25 | httpx==0.28.1 26 | # via -r requirements.in 27 | idna==3.10 28 | # via 29 | # anyio 30 | # httpx 31 | markdown-it-py==3.0.0 32 | # via rich 33 | mdurl==0.1.2 34 | # via markdown-it-py 35 | pygments==2.18.0 36 | # via 37 | # httpx 38 | # rich 39 | python-dateutil==2.9.0.post0 40 | # via arrow 41 | rich==13.9.4 42 | # via httpx 43 | six==1.17.0 44 | # via python-dateutil 45 | sniffio==1.3.1 46 | # via anyio 47 | types-python-dateutil==2.9.0.20241206 48 | # via arrow 49 | uvicorn @ git+https://github.com/encode/uvicorn 50 | # via -r requirements.in 51 | zstandard==0.23.0 52 | # via httpx 53 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/with_lock_file/requirements-dev.in: -------------------------------------------------------------------------------- 1 | -c requirements.txt 2 | factory-boy>=3.2.1 3 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/with_lock_file/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile requirements-dev.in 6 | # 7 | factory-boy==3.2.1 8 | # via -r requirements-dev.in 9 | faker==33.1.0 10 | # via factory-boy 11 | python-dateutil==2.7.0 12 | # via 13 | # -c requirements.txt 14 | # faker 15 | six==1.15.0 16 | # via 17 | # -c requirements.txt 18 | # python-dateutil 19 | typing-extensions==4.6.0 20 | # via faker 21 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/with_lock_file/requirements-typing.in: -------------------------------------------------------------------------------- 1 | -c requirements-dev.txt 2 | mypy>=1.13.0 3 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/with_lock_file/requirements-typing.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile requirements-typing.in 6 | # 7 | mypy==1.13.0 8 | # via -r requirements-typing.in 9 | mypy-extensions==1.0.0 10 | # via mypy 11 | typing-extensions==4.6.0 12 | # via 13 | # -c requirements-dev.txt 14 | # mypy 15 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/with_lock_file/requirements.in: -------------------------------------------------------------------------------- 1 | arrow>=1.2.3 2 | -------------------------------------------------------------------------------- /tests/fixtures/pip_tools/with_lock_file/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | arrow==1.2.3 8 | # via -r requirements.in 9 | python-dateutil==2.7.0 10 | # via arrow 11 | six==1.15.0 12 | # via python-dateutil 13 | -------------------------------------------------------------------------------- /tests/fixtures/pipenv/existing_project/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | arrow = ">=1.2.3" 8 | 9 | [dev-packages] 10 | mypy = ">=1.13.0" 11 | 12 | [test] 13 | factory-boy = ">=3.2.1" 14 | 15 | [requires] 16 | python_version = "3.13" 17 | -------------------------------------------------------------------------------- /tests/fixtures/pipenv/existing_project/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "foobar" 3 | version = "1.0.0" 4 | requires-python = ">=3.13" 5 | -------------------------------------------------------------------------------- /tests/fixtures/pipenv/full/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [[source]] 7 | url = "https://example.com/simple" 8 | verify_ssl = true 9 | name = "other-index" 10 | 11 | [packages] 12 | dep = "==1.2.3" 13 | dep-2 = ">=1.2.3" 14 | dep-3 = "~=1.2.3" 15 | dep-4 = "~=1.2" 16 | dep-star = "*" 17 | 18 | # Tables 19 | with-version-only = { version = "==1.2.3" } 20 | with-version-only-star = { version = "*" } 21 | with-extras = { version = "==1.2.3", extras = ["foo", "bar"] } 22 | with-source = { version = "==1.2.3", index = "other-index" } 23 | 24 | # Path 25 | local-package = { path = "package/" } 26 | local-package-2 = { path = "another-package/", editable = false } 27 | local-package-editable = { path = "package/dist/package-0.1.0.tar.gz", editable = true } 28 | 29 | # Git 30 | git = { git = "https://example.com/foo/bar.git" } 31 | git-ref = { git = "https://example.com/foo/bar.git", ref = "v1.2.3" } 32 | 33 | # Markers 34 | markers = { version = "==1.2.3", markers = "sys_platform == 'win32'" } 35 | markers-2 = { version = "==1.2.3", markers = "sys_platform == 'win32'", os_name= "== 'nt'", sys_platform = "!= 'darwin'", platform_machine = "== 'x86_64'", platform_python_implementation = "== 'CPython'", platform_release = "== '1.2.3'", platform_system = "== 'Windows'", platform_version = "== '1.2.3'", python_version = "> '3.8'", python_full_version = "> '3.8.0'", implementation_name = "!= 'pypy'", implementation_version = "> '3.8'", additional_key = "foobar" } 36 | 37 | [dev-packages] 38 | dev-package = "==1.2.3" 39 | dev-package-local = { path = "package" } 40 | dev-package-source = { path = "package", index = "other-index" } 41 | 42 | [packages-category] 43 | category-package = "==1.2.3" 44 | category-package-2 = { version = "==1.2.3", index = "other-index" } 45 | 46 | [packages-category-2] 47 | category-2-package = { version = "==1.2.3", index = "other-index" } 48 | category-2-package-2 = { git = "https://example.com/foo/bar.git", ref = "v1.2.3", markers = "sys_platform == 'win32'" } 49 | 50 | [requires] 51 | python_version = "3.13" 52 | python_full_version = "3.13.1" 53 | 54 | [pipenv] 55 | allow_prereleases = true 56 | install_search_all_sources = true 57 | extra-key = "bar" 58 | 59 | [scripts] 60 | "foo" = "bar:run" 61 | -------------------------------------------------------------------------------- /tests/fixtures/pipenv/full/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | fix = true 3 | 4 | [tool.ruff.format] 5 | preview = true 6 | -------------------------------------------------------------------------------- /tests/fixtures/pipenv/minimal/Pipfile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkniewallner/migrate-to-uv/b0679e1c8e7b9ef39820031f624727fbb1848215/tests/fixtures/pipenv/minimal/Pipfile -------------------------------------------------------------------------------- /tests/fixtures/pipenv/with_lock_file/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | arrow = ">=1.2.3" 8 | 9 | [dev-packages] 10 | mypy = ">=1.13.0" 11 | 12 | [test] 13 | factory-boy = ">=3.2.1" 14 | -------------------------------------------------------------------------------- /tests/fixtures/pipenv/with_lock_file/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "7ecb5e9587be05fc0f32ad07c192fc2216c54cfb965db1d829c1659de7a19c5f" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "arrow": { 18 | "hashes": [ 19 | "sha256:3934b30ca1b9f292376d9db15b19446088d12ec58629bc3f0da28fd55fb633a1", 20 | "sha256:5a49ab92e3b7b71d96cd6bfcc4df14efefc9dfa96ea19045815914a6ab6b1fe2" 21 | ], 22 | "index": "pypi", 23 | "markers": "python_version >= '3.6'", 24 | "version": "==1.2.3" 25 | }, 26 | "faker": { 27 | "hashes": [ 28 | "sha256:1c925fc0e86a51fc46648b504078c88d0cd48da1da2595c4e712841cab43a1e4", 29 | "sha256:d30c5f0e2796b8970de68978365247657486eb0311c5abe88d0b895b68dff05d" 30 | ], 31 | "index": "pypi", 32 | "markers": "python_version >= '3.8'", 33 | "version": "==33.1.0" 34 | }, 35 | "python-dateutil": { 36 | "hashes": [ 37 | "sha256:07009062406cffd554a9b4135cd2ff167c9bf6b7aac61fe946c93e69fad1bbd8", 38 | "sha256:8f95bb7e6edbb2456a51a1fb58c8dca942024b4f5844cae62c90aa88afe6e300" 39 | ], 40 | "index": "pypi", 41 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", 42 | "version": "==2.7.0" 43 | }, 44 | "six": { 45 | "hashes": [ 46 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 47 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 48 | ], 49 | "index": "pypi", 50 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", 51 | "version": "==1.15.0" 52 | }, 53 | "typing-extensions": { 54 | "hashes": [ 55 | "sha256:6ad00b63f849b7dcc313b70b6b304ed67b2b2963b3098a33efe18056b1a9a223", 56 | "sha256:ff6b238610c747e44c268aa4bb23c8c735d665a63726df3f9431ce707f2aa768" 57 | ], 58 | "index": "pypi", 59 | "markers": "python_version >= '3.7'", 60 | "version": "==4.6.0" 61 | } 62 | }, 63 | "develop": { 64 | "mypy": { 65 | "hashes": [ 66 | "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc", 67 | "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", 68 | "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", 69 | "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74", 70 | "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a", 71 | "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", 72 | "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", 73 | "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", 74 | "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", 75 | "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", 76 | "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d", 77 | "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6", 78 | "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", 79 | "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", 80 | "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", 81 | "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", 82 | "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", 83 | "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc", 84 | "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", 85 | "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb", 86 | "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", 87 | "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732", 88 | "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", 89 | "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", 90 | "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", 91 | "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", 92 | "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", 93 | "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24", 94 | "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", 95 | "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b", 96 | "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", 97 | "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8" 98 | ], 99 | "index": "pypi", 100 | "markers": "python_version >= '3.8'", 101 | "version": "==1.13.0" 102 | }, 103 | "mypy-extensions": { 104 | "hashes": [ 105 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 106 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 107 | ], 108 | "markers": "python_version >= '3.5'", 109 | "version": "==1.0.0" 110 | }, 111 | "typing-extensions": { 112 | "hashes": [ 113 | "sha256:6ad00b63f849b7dcc313b70b6b304ed67b2b2963b3098a33efe18056b1a9a223", 114 | "sha256:ff6b238610c747e44c268aa4bb23c8c735d665a63726df3f9431ce707f2aa768" 115 | ], 116 | "index": "pypi", 117 | "markers": "python_version >= '3.7'", 118 | "version": "==4.6.0" 119 | } 120 | }, 121 | "test": { 122 | "factory-boy": { 123 | "hashes": [ 124 | "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e", 125 | "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795" 126 | ], 127 | "index": "pypi", 128 | "markers": "python_version >= '3.6'", 129 | "version": "==3.2.1" 130 | }, 131 | "faker": { 132 | "hashes": [ 133 | "sha256:1c925fc0e86a51fc46648b504078c88d0cd48da1da2595c4e712841cab43a1e4", 134 | "sha256:d30c5f0e2796b8970de68978365247657486eb0311c5abe88d0b895b68dff05d" 135 | ], 136 | "index": "pypi", 137 | "markers": "python_version >= '3.8'", 138 | "version": "==33.1.0" 139 | }, 140 | "python-dateutil": { 141 | "hashes": [ 142 | "sha256:07009062406cffd554a9b4135cd2ff167c9bf6b7aac61fe946c93e69fad1bbd8", 143 | "sha256:8f95bb7e6edbb2456a51a1fb58c8dca942024b4f5844cae62c90aa88afe6e300" 144 | ], 145 | "index": "pypi", 146 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", 147 | "version": "==2.7.0" 148 | }, 149 | "six": { 150 | "hashes": [ 151 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 152 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 153 | ], 154 | "index": "pypi", 155 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", 156 | "version": "==1.15.0" 157 | }, 158 | "typing-extensions": { 159 | "hashes": [ 160 | "sha256:6ad00b63f849b7dcc313b70b6b304ed67b2b2963b3098a33efe18056b1a9a223", 161 | "sha256:ff6b238610c747e44c268aa4bb23c8c735d665a63726df3f9431ce707f2aa768" 162 | ], 163 | "index": "pypi", 164 | "markers": "python_version >= '3.7'", 165 | "version": "==4.6.0" 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /tests/fixtures/poetry/existing_project/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "foobar" 3 | version = "1.0.0" 4 | requires-python = ">=3.13" 5 | 6 | [tool.poetry] 7 | name = "foo" 8 | version = "0.0.1" 9 | description = "A description" 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.11" 13 | arrow = "^1.2.3" 14 | 15 | [tool.poetry.group.dev.dependencies] 16 | factory-boy = "^3.2.1" 17 | 18 | [tool.poetry.group.typing.dependencies] 19 | mypy = "^1.13.0" 20 | -------------------------------------------------------------------------------- /tests/fixtures/poetry/full/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.0.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | package-mode = false 7 | name = "foobar" 8 | version = "0.1.0" 9 | description = "A fabulous project." 10 | license = "MIT" 11 | authors = ["John Doe "] 12 | maintainers = ["Dohn Joe ", "Johd Noe"] 13 | readme = "README.md" 14 | keywords = [ 15 | "foo", 16 | "bar", 17 | "foobar", 18 | ] 19 | classifiers = [ 20 | "Development Status :: 3 - Alpha", 21 | "Environment :: Console", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: MIT License", 24 | "Topic :: Software Development :: Libraries :: Python Modules", 25 | "Operating System :: OS Independent", 26 | ] 27 | homepage = "https://homepage.example.com" 28 | repository = "https://repository.example.com" 29 | documentation = "https://docs.example.com" 30 | 31 | # Package metadata 32 | packages = [ 33 | { include = "packages-sdist-wheel" }, 34 | { include = "packages-sdist-wheel-2", format = [] }, 35 | { include = "packages-sdist-wheel-3/**/*.py", format = ["sdist", "wheel"] }, 36 | { include = "packages-sdist", format = "sdist" }, 37 | { include = "packages-sdist-2", format = ["sdist"] }, 38 | { include = "packages-wheel", format = "wheel" }, 39 | { include = "packages-wheel-2", format = ["wheel"] }, 40 | { include = "packages-from", from = "from" }, 41 | { include = "packages-to", to = "to" }, 42 | { include = "packages-from-to", from = "from", to = "to" }, 43 | { include = "packages-glob-to/**/*.py", to = "to" }, 44 | { include = "packages-glob-from-to/**/*.py", from = "from", to = "to" }, 45 | ] 46 | include = [ 47 | "include-sdist-wheel", 48 | { path = "include-sdist-wheel-2" }, 49 | { path = "include-sdist-wheel-3", format = [] }, 50 | { path = "include-sdist-wheel-4", format = ["sdist", "wheel"] }, 51 | { path = "include-sdist", format = "sdist" }, 52 | { path = "include-sdist-2", format = ["sdist"] }, 53 | { path = "include-wheel", format = "wheel" }, 54 | { path = "include-wheel-2", format = ["wheel"] }, 55 | ] 56 | exclude = [ 57 | "exclude-sdist-wheel", 58 | "exclude-sdist-wheel-2", 59 | ] 60 | 61 | [tool.poetry.dependencies] 62 | # Python version 63 | python = "^3.11" # ~=3.11 64 | 65 | # Caret 66 | caret = "^1.2.3" # >=1.2.3,<2 67 | caret-2 = "^1.2" # >=1.2.0,<2 or ~=1.2 68 | caret-3 = "^1" # >=1.0.0,<2 69 | caret-4 = "^0.2.3" # >=0.2.3,<0.3 70 | caret-5 = "^0.0.3" # >=0.0.3,<0.0.4 71 | caret-6 = "^0.0" # >=0.0.0,<0.1 72 | caret-7 = "^0" # >=0.0.0,<1 73 | caret-8 = "^1.2.3.4" # >=1.2.3.4,<2 74 | caret-9 = "^0.1.2.3" # >=0.1.2.3,<0.2 75 | caret-pre-release = "^1.2.3b1" # >=1.2.3b1,<2 76 | 77 | # Tilde 78 | tilde = "~1.2.3" # >=1.2.3,<1.3.0 or ~=1.2.3 79 | tilde-2 = "~1.2" # >=1.2.0,<1.3 80 | tilde-3 = "~1" # >=1.0.0,<2 81 | tilde-4 = "~1.2.3.4" # >=1.2.3.4,<1.3.0.0 or ~=1.2.3.4 82 | tilde-pre-release = "~1.2.3b1" # >=1.2.3b1,<1.3 or ~=1.2.3 83 | 84 | # Almost PEP 440 85 | exact = "1.2.3" # ==1.2.3 86 | star = "*" # (no version specifier) 87 | star-2 = "1.*" # ==1.* 88 | star-3 = "1.2.*" # ==1.2.* 89 | 90 | # PEP 440 91 | pep440 = ">=1.2.3" # >=1.2.3 (already compliant) 92 | 93 | # Tables 94 | with-version-only = { version = "1.2.3" } 95 | with-extras = { version = "1.2.3", extras = ["asyncio", "postgresql_asyncpg"] } 96 | with-markers = { version = "1.2.3", markers = "python_version <= '3.11' or sys_platform == 'win32'" } 97 | with-platform = { version = "1.2.3", platform = "darwin" } 98 | with-markers-python-platform = { version = "1.2.3", python = "~3.11", platform = "darwin", markers = "platform_python_implementation == 'CPython' or platform_python_implementation == 'Jython'", additional_key = "foobar" } 99 | with-source = { version = "1.2.3", source = "supplemental" } 100 | 101 | python-restricted = { version = "1.2.3", python = "^3.11" } 102 | python-restricted-2 = { version = "1.2.3", python = "~3.11" } 103 | python-restricted-3 = { version = "1.2.3", python = ">3.11" } 104 | python-restricted-4 = { version = "1.2.3", python = ">=3.11" } 105 | python-restricted-5 = { version = "1.2.3", python = "<3.11" } 106 | python-restricted-6 = { version = "1.2.3", python = "<=3.11" } 107 | python-restricted-7 = { version = "1.2.3", python = ">3.11,<3.13" } 108 | python-restricted-with-source = { version = "1.2.3", python = ">3.11,<3.13", source = "supplemental" } 109 | 110 | # Going wild 111 | whitespaces = " ^ 3.2 " 112 | whitespaces-2 = { version = " > 3.11, <= 3.13 " } 113 | 114 | # Extras and optional 115 | dep-in-extra = { version = "1.2.3" } 116 | optional-in-extra = { version = "1.2.3", optional = true } 117 | optional-not-in-extra = { version = "1.2.3", optional = true } 118 | 119 | # Path 120 | local-package = { path = "package/" } 121 | local-package-2 = { path = "package/dist/package-0.1.0.tar.gz", develop = false } 122 | local-package-editable = { path = "editable-package/", develop = true } 123 | 124 | # URL 125 | url-dep = { url = "https://example.com/package-0.0.1.tar.gz" } 126 | 127 | # Git 128 | git = { git = "https://example.com/foo/bar" } 129 | git-branch = { git = "https://example.com/foo/bar", branch = "foo" } 130 | git-rev = { git = "https://example.com/foo/bar", rev = "1234567" } 131 | git-tag = { git = "https://example.com/foo/bar", tag = "v1.2.3" } 132 | git-subdirectory = { git = "https://example.com/foo/bar", subdirectory = "directory" } 133 | 134 | # Multiple constraints 135 | multiple-constraints-python-version = [ 136 | { python = ">=3.11", version = ">=2" }, 137 | { python = "<3.11", version = "<2" }, 138 | ] 139 | multiple-constraints-platform-version = [ 140 | { platform = "darwin", version = ">=2" }, 141 | { platform = "linux", version = "<2" }, 142 | ] 143 | multiple-constraints-markers-version = [ 144 | { markers = "platform_python_implementation == 'CPython'", version = ">=2" }, 145 | { markers = "platform_python_implementation != 'CPython'", version = "<2" }, 146 | ] 147 | multiple-constraints-python-platform-markers-version = [ 148 | { python = ">=3.11", platform = "darwin", markers = "platform_python_implementation == 'CPython'", version = ">=2" }, 149 | { python = "<3.11", platform = "linux", markers = "platform_python_implementation != 'CPython'", version = "<2" }, 150 | ] 151 | multiple-constraints-python-source = [ 152 | { python = ">=3.11", url = "https://example.com/foo-1.2.3-py3-none-any.whl" }, 153 | { python = "<3.11", git = "https://example.com/foo/bar", tag = "v1.2.3" }, 154 | ] 155 | multiple-constraints-platform-source = [ 156 | { platform = "darwin", url = "https://example.com/foo-1.2.3-py3-none-any.whl" }, 157 | { platform = "linux", git = "https://example.com/foo/bar", tag = "v1.2.3" }, 158 | ] 159 | multiple-constraints-markers-source = [ 160 | { markers = "platform_python_implementation == 'CPython'", url = "https://example.com/foo-1.2.3-py3-none-any.whl" }, 161 | { markers = "platform_python_implementation != 'CPython'", git = "https://example.com/foo/bar", tag = "v1.2.3" }, 162 | ] 163 | multiple-constraints-python-platform-markers-source = [ 164 | { python = ">=3.11", platform = "darwin", markers = "platform_python_implementation == 'CPython'", url = "https://example.com/foo-1.2.3-py3-none-any.whl" }, 165 | { python = "<3.11", platform = "linux", markers = "platform_python_implementation != 'CPython'", source = "supplemental" }, 166 | ] 167 | 168 | [tool.poetry.extras] 169 | extra = ["dep-in-extra"] 170 | extra-2 = ["dep-in-extra", "optional-in-extra"] 171 | extra-with-non-existing-dependencies = ["non-existing-dependency"] 172 | 173 | [tool.poetry.dev-dependencies] 174 | dev-legacy = "1.2.3" 175 | dev-legacy-2 = "1.2.3" 176 | 177 | [tool.poetry.group.dev.dependencies] 178 | dev-dep = "1.2.3" 179 | 180 | [tool.poetry.group.typing.dependencies] 181 | typing-dep = "1.2.3" 182 | 183 | [tool.poetry.urls] 184 | "First link" = "https://first.example.com" 185 | "Another link" = "https://another.example.com" 186 | 187 | [tool.poetry.scripts] 188 | console-script = "foo:run" 189 | # Although it's possible to set `extras`, it doesn't seem to actually do 190 | # anything (https://github.com/python-poetry/poetry/issues/6892). 191 | console-script-2 = { callable = "bar:run", extras = ["extra"] } 192 | 193 | [tool.poetry.plugins.console_scripts] 194 | console-script-2 = "override_bar:run" 195 | console-script-3 = "foobar:run" 196 | 197 | [tool.poetry.plugins.gui_scripts] 198 | gui-script = "gui:run" 199 | 200 | [tool.poetry.plugins.some-scripts] 201 | a-script = "a_script:run" 202 | another-script = "another_script:run" 203 | 204 | [tool.poetry.plugins.other-scripts] 205 | a-script = "another_script:run" 206 | yet-another-script = "yet_another_scripts:run" 207 | 208 | [[tool.poetry.source]] 209 | name = "PyPI" 210 | priority = "primary" 211 | 212 | [[tool.poetry.source]] 213 | name = "secondary" 214 | url = "https://secondary.example.com/simple/" 215 | priority = "secondary" 216 | 217 | [[tool.poetry.source]] 218 | name = "supplemental" 219 | url = "https://supplemental.example.com/simple/" 220 | priority = "supplemental" 221 | 222 | [[tool.poetry.source]] 223 | name = "explicit" 224 | url = "https://explicit.example.com/simple/" 225 | priority = "explicit" 226 | 227 | [[tool.poetry.source]] 228 | name = "default" 229 | url = "https://default.example.com/simple/" 230 | priority = "default" 231 | 232 | [tool.ruff] 233 | fix = true 234 | 235 | [tool.ruff.lint] 236 | # This comment should be preserved. 237 | fixable = ["I", "UP"] 238 | 239 | [tool.ruff.format] 240 | preview = true 241 | -------------------------------------------------------------------------------- /tests/fixtures/poetry/minimal/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "foobar" 3 | 4 | [tool.ruff] 5 | fix = true 6 | 7 | [tool.ruff.format] 8 | preview = true 9 | -------------------------------------------------------------------------------- /tests/fixtures/poetry/pep_621/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.0.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [project] 6 | name = "foobar" 7 | version = "0.1.0" 8 | description = "A fabulous project." 9 | license = "MIT" 10 | authors = [{name = "John Doe", email = "john.doe@example.com"}] 11 | maintainers = [{name = "Dohn Joe", email = "dohn.joe@example.com"}] 12 | readme = "README.md" 13 | keywords = ["foo"] 14 | classifiers = ["Development Status :: 3 - Alpha"] 15 | requires-python = ">=3.11" 16 | dependencies = [ 17 | "arrow==1.2.3", 18 | "git-dep", 19 | "private-dep==3.4.5", 20 | ] 21 | 22 | [tool.poetry.dependencies] 23 | git-dep = { git = "https://example.com/foo/bar", tag = "v1.2.3" } 24 | private-dep = { source = "supplemental" } 25 | 26 | [tool.poetry.group.dev.dependencies] 27 | factory-boy = "^3.2.1" 28 | 29 | [tool.poetry.group.typing.dependencies] 30 | mypy = "^1.13.0" 31 | 32 | [[tool.poetry.source]] 33 | name = "PyPI" 34 | priority = "primary" 35 | 36 | [[tool.poetry.source]] 37 | name = "supplemental" 38 | url = "https://supplemental.example.com/simple/" 39 | priority = "supplemental" 40 | 41 | [tool.ruff] 42 | fix = true 43 | 44 | [tool.ruff.lint] 45 | # This comment should be preserved. 46 | fixable = ["I", "UP"] 47 | 48 | [tool.ruff.format] 49 | preview = true 50 | -------------------------------------------------------------------------------- /tests/fixtures/poetry/with_lock_file/poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "arrow" 5 | version = "1.2.3" 6 | description = "Better dates & times for Python" 7 | optional = false 8 | python-versions = ">=3.6" 9 | files = [ 10 | {file = "arrow-1.2.3-py3-none-any.whl", hash = "sha256:5a49ab92e3b7b71d96cd6bfcc4df14efefc9dfa96ea19045815914a6ab6b1fe2"}, 11 | {file = "arrow-1.2.3.tar.gz", hash = "sha256:3934b30ca1b9f292376d9db15b19446088d12ec58629bc3f0da28fd55fb633a1"}, 12 | ] 13 | 14 | [package.dependencies] 15 | python-dateutil = ">=2.7.0" 16 | 17 | [[package]] 18 | name = "factory-boy" 19 | version = "3.2.1" 20 | description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." 21 | optional = false 22 | python-versions = ">=3.6" 23 | files = [ 24 | {file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"}, 25 | {file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"}, 26 | ] 27 | 28 | [package.dependencies] 29 | Faker = ">=0.7.0" 30 | 31 | [package.extras] 32 | dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoengine", "tox", "wheel (>=0.32.0)", "zest.releaser[recommended]"] 33 | doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] 34 | 35 | [[package]] 36 | name = "faker" 37 | version = "33.1.0" 38 | description = "Faker is a Python package that generates fake data for you." 39 | optional = false 40 | python-versions = ">=3.8" 41 | files = [ 42 | {file = "Faker-33.1.0-py3-none-any.whl", hash = "sha256:d30c5f0e2796b8970de68978365247657486eb0311c5abe88d0b895b68dff05d"}, 43 | {file = "faker-33.1.0.tar.gz", hash = "sha256:1c925fc0e86a51fc46648b504078c88d0cd48da1da2595c4e712841cab43a1e4"}, 44 | ] 45 | 46 | [package.dependencies] 47 | python-dateutil = ">=2.4" 48 | typing-extensions = "*" 49 | 50 | [[package]] 51 | name = "mypy" 52 | version = "1.13.0" 53 | description = "Optional static typing for Python" 54 | optional = false 55 | python-versions = ">=3.8" 56 | files = [ 57 | {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, 58 | {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, 59 | {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, 60 | {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, 61 | {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, 62 | {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, 63 | {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, 64 | {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, 65 | {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, 66 | {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, 67 | {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, 68 | {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, 69 | {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, 70 | {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, 71 | {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, 72 | {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, 73 | {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, 74 | {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, 75 | {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, 76 | {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, 77 | {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, 78 | {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, 79 | {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, 80 | {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, 81 | {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, 82 | {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, 83 | {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, 84 | {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, 85 | {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, 86 | {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, 87 | {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, 88 | {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, 89 | ] 90 | 91 | [package.dependencies] 92 | mypy-extensions = ">=1.0.0" 93 | typing-extensions = ">=4.6.0" 94 | 95 | [package.extras] 96 | dmypy = ["psutil (>=4.0)"] 97 | faster-cache = ["orjson"] 98 | install-types = ["pip"] 99 | mypyc = ["setuptools (>=50)"] 100 | reports = ["lxml"] 101 | 102 | [[package]] 103 | name = "mypy-extensions" 104 | version = "1.0.0" 105 | description = "Type system extensions for programs checked with the mypy type checker." 106 | optional = false 107 | python-versions = ">=3.5" 108 | files = [ 109 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 110 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 111 | ] 112 | 113 | [[package]] 114 | name = "python-dateutil" 115 | version = "2.7.0" 116 | description = "Extensions to the standard Python datetime module" 117 | optional = false 118 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 119 | files = [ 120 | {file = "python-dateutil-2.7.0.tar.gz", hash = "sha256:8f95bb7e6edbb2456a51a1fb58c8dca942024b4f5844cae62c90aa88afe6e300"}, 121 | {file = "python_dateutil-2.7.0-py2.py3-none-any.whl", hash = "sha256:07009062406cffd554a9b4135cd2ff167c9bf6b7aac61fe946c93e69fad1bbd8"}, 122 | ] 123 | 124 | [package.dependencies] 125 | six = ">=1.5" 126 | 127 | [[package]] 128 | name = "six" 129 | version = "1.15.0" 130 | description = "Python 2 and 3 compatibility utilities" 131 | optional = false 132 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 133 | files = [ 134 | {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, 135 | {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, 136 | ] 137 | 138 | [[package]] 139 | name = "typing-extensions" 140 | version = "4.6.0" 141 | description = "Backported and Experimental Type Hints for Python 3.7+" 142 | optional = false 143 | python-versions = ">=3.7" 144 | files = [ 145 | {file = "typing_extensions-4.6.0-py3-none-any.whl", hash = "sha256:6ad00b63f849b7dcc313b70b6b304ed67b2b2963b3098a33efe18056b1a9a223"}, 146 | {file = "typing_extensions-4.6.0.tar.gz", hash = "sha256:ff6b238610c747e44c268aa4bb23c8c735d665a63726df3f9431ce707f2aa768"}, 147 | ] 148 | 149 | [metadata] 150 | lock-version = "2.0" 151 | python-versions = "^3.11" 152 | content-hash = "fba2c84972b095df2695db9c529441ec58dc167df9aed0b955e6c25741643a12" 153 | -------------------------------------------------------------------------------- /tests/fixtures/poetry/with_lock_file/poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /tests/fixtures/poetry/with_lock_file/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | package-mode = false 3 | name = "foo" 4 | 5 | [tool.poetry.dependencies] 6 | python = "^3.11" 7 | arrow = "^1.2.3" 8 | 9 | [tool.poetry.group.dev.dependencies] 10 | factory-boy = "^3.2.1" 11 | 12 | [tool.poetry.group.typing.dependencies] 13 | mypy = "^1.13.0" 14 | -------------------------------------------------------------------------------- /tests/fixtures/uv/minimal/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.5,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "test-project" 7 | version = "0.1.0" 8 | 9 | [tool.uv] 10 | package = false 11 | -------------------------------------------------------------------------------- /tests/fixtures/uv/with_lock/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.5,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "test-project" 7 | version = "0.1.0" 8 | -------------------------------------------------------------------------------- /tests/fixtures/uv/with_lock/uv.lock: -------------------------------------------------------------------------------- 1 | # This is a uv lockfile. It is automatically generated by uv. 2 | # If you want to learn more, visit https://github.com/astral-sh/uv 3 | -------------------------------------------------------------------------------- /tests/pip.rs: -------------------------------------------------------------------------------- 1 | use crate::common::{apply_lock_filters, cli}; 2 | use insta_cmd::assert_cmd_snapshot; 3 | use std::path::Path; 4 | use std::{env, fs}; 5 | use tempfile::tempdir; 6 | 7 | mod common; 8 | 9 | const FIXTURES_PATH: &str = "tests/fixtures/pip"; 10 | 11 | #[test] 12 | fn test_complete_workflow() { 13 | let fixture_path = Path::new(FIXTURES_PATH).join("full"); 14 | let requirements_files = [ 15 | "requirements.txt", 16 | "requirements-dev.txt", 17 | "requirements-typing.txt", 18 | ]; 19 | 20 | let tmp_dir = tempdir().unwrap(); 21 | let project_path = tmp_dir.path(); 22 | 23 | for file in requirements_files { 24 | fs::copy(fixture_path.join(file), project_path.join(file)).unwrap(); 25 | } 26 | 27 | apply_lock_filters!(); 28 | assert_cmd_snapshot!(cli() 29 | .arg(project_path) 30 | .arg("--dev-requirements-file") 31 | .arg("requirements-dev.txt") 32 | .arg("--dev-requirements-file") 33 | .arg("requirements-typing.txt"), @r###" 34 | success: true 35 | exit_code: 0 36 | ----- stdout ----- 37 | 38 | ----- stderr ----- 39 | warning: Could not parse the following dependency specification as a PEP 508 one: file:bar 40 | warning: Could not parse the following dependency specification as a PEP 508 one: file:./bar 41 | warning: Could not parse the following dependency specification as a PEP 508 one: git+https://github.com/psf/requests 42 | warning: Could not parse the following dependency specification as a PEP 508 one: git+https://github.com/psf/requests#egg=requests 43 | Locking dependencies with "uv lock"... 44 | Using [PYTHON_INTERPRETER] 45 | warning: No `requires-python` value found in the workspace. Defaulting to `[PYTHON_VERSION]`. 46 | Updating https://github.com/encode/uvicorn (HEAD) 47 | Updated https://github.com/encode/uvicorn ([SHA1]) 48 | Resolved [PACKAGES] packages in [TIME] 49 | Successfully migrated project from pip to uv! 50 | "###); 51 | 52 | insta::assert_snapshot!(fs::read_to_string(project_path.join("pyproject.toml")).unwrap(), @r#" 53 | [project] 54 | name = "" 55 | version = "0.0.1" 56 | dependencies = [ 57 | "arrow==1.3.0", 58 | "httpx[cli]==0.28.1", 59 | "uvicorn @ git+https://github.com/encode/uvicorn", 60 | "requests==2.32.3", 61 | ] 62 | 63 | [dependency-groups] 64 | dev = [ 65 | "pytest==8.3.4", 66 | "ruff==0.8.4", 67 | "mypy==1.14.1", 68 | "types-jsonschema==4.23.0.20241208", 69 | ] 70 | 71 | [tool.uv] 72 | package = false 73 | "#); 74 | 75 | // Assert that previous package manager files are correctly removed. 76 | for file in requirements_files { 77 | assert!(!project_path.join(file).exists()); 78 | } 79 | } 80 | 81 | #[test] 82 | fn test_keep_current_data() { 83 | let fixture_path = Path::new(FIXTURES_PATH).join("full"); 84 | let requirements_files = [ 85 | "requirements.txt", 86 | "requirements-dev.txt", 87 | "requirements-typing.txt", 88 | ]; 89 | 90 | let tmp_dir = tempdir().unwrap(); 91 | let project_path = tmp_dir.path(); 92 | 93 | for file in requirements_files { 94 | fs::copy(fixture_path.join(file), project_path.join(file)).unwrap(); 95 | } 96 | 97 | apply_lock_filters!(); 98 | assert_cmd_snapshot!(cli() 99 | .arg(project_path) 100 | .arg("--dev-requirements-file") 101 | .arg("requirements-dev.txt") 102 | .arg("--dev-requirements-file") 103 | .arg("requirements-typing.txt") 104 | .arg("--keep-current-data"), @r###" 105 | success: true 106 | exit_code: 0 107 | ----- stdout ----- 108 | 109 | ----- stderr ----- 110 | warning: Could not parse the following dependency specification as a PEP 508 one: file:bar 111 | warning: Could not parse the following dependency specification as a PEP 508 one: file:./bar 112 | warning: Could not parse the following dependency specification as a PEP 508 one: git+https://github.com/psf/requests 113 | warning: Could not parse the following dependency specification as a PEP 508 one: git+https://github.com/psf/requests#egg=requests 114 | Locking dependencies with "uv lock"... 115 | Using [PYTHON_INTERPRETER] 116 | warning: No `requires-python` value found in the workspace. Defaulting to `[PYTHON_VERSION]`. 117 | Updating https://github.com/encode/uvicorn (HEAD) 118 | Updated https://github.com/encode/uvicorn ([SHA1]) 119 | Resolved [PACKAGES] packages in [TIME] 120 | Successfully migrated project from pip to uv! 121 | "###); 122 | 123 | insta::assert_snapshot!(fs::read_to_string(project_path.join("pyproject.toml")).unwrap(), @r#" 124 | [project] 125 | name = "" 126 | version = "0.0.1" 127 | dependencies = [ 128 | "arrow==1.3.0", 129 | "httpx[cli]==0.28.1", 130 | "uvicorn @ git+https://github.com/encode/uvicorn", 131 | "requests==2.32.3", 132 | ] 133 | 134 | [dependency-groups] 135 | dev = [ 136 | "pytest==8.3.4", 137 | "ruff==0.8.4", 138 | "mypy==1.14.1", 139 | "types-jsonschema==4.23.0.20241208", 140 | ] 141 | 142 | [tool.uv] 143 | package = false 144 | "#); 145 | 146 | // Assert that previous package manager files have not been removed. 147 | for file in requirements_files { 148 | assert!(project_path.join(file).exists()); 149 | } 150 | } 151 | 152 | #[test] 153 | fn test_skip_lock() { 154 | let fixture_path = Path::new(FIXTURES_PATH).join("full"); 155 | let requirements_files = [ 156 | "requirements.txt", 157 | "requirements-dev.txt", 158 | "requirements-typing.txt", 159 | ]; 160 | 161 | let tmp_dir = tempdir().unwrap(); 162 | let project_path = tmp_dir.path(); 163 | 164 | for file in requirements_files { 165 | fs::copy(fixture_path.join(file), project_path.join(file)).unwrap(); 166 | } 167 | 168 | assert_cmd_snapshot!(cli() 169 | .arg(project_path) 170 | .arg("--dev-requirements-file") 171 | .arg("requirements-dev.txt") 172 | .arg("--dev-requirements-file") 173 | .arg("requirements-typing.txt") 174 | .arg("--skip-lock"), @r" 175 | success: true 176 | exit_code: 0 177 | ----- stdout ----- 178 | 179 | ----- stderr ----- 180 | warning: Could not parse the following dependency specification as a PEP 508 one: file:bar 181 | warning: Could not parse the following dependency specification as a PEP 508 one: file:./bar 182 | warning: Could not parse the following dependency specification as a PEP 508 one: git+https://github.com/psf/requests 183 | warning: Could not parse the following dependency specification as a PEP 508 one: git+https://github.com/psf/requests#egg=requests 184 | Successfully migrated project from pip to uv! 185 | "); 186 | 187 | insta::assert_snapshot!(fs::read_to_string(project_path.join("pyproject.toml")).unwrap(), @r#" 188 | [project] 189 | name = "" 190 | version = "0.0.1" 191 | dependencies = [ 192 | "arrow==1.3.0", 193 | "httpx[cli]==0.28.1", 194 | "uvicorn @ git+https://github.com/encode/uvicorn", 195 | "requests==2.32.3", 196 | ] 197 | 198 | [dependency-groups] 199 | dev = [ 200 | "pytest==8.3.4", 201 | "ruff==0.8.4", 202 | "mypy==1.14.1", 203 | "types-jsonschema==4.23.0.20241208", 204 | ] 205 | 206 | [tool.uv] 207 | package = false 208 | "#); 209 | 210 | // Assert that previous package manager files are correctly removed. 211 | for file in requirements_files { 212 | assert!(!project_path.join(file).exists()); 213 | } 214 | 215 | // Assert that `uv.lock` file was not generated. 216 | assert!(!project_path.join("uv.lock").exists()); 217 | } 218 | 219 | #[test] 220 | fn test_dry_run() { 221 | let project_path = Path::new(FIXTURES_PATH).join("full"); 222 | let requirements_files = [ 223 | "requirements.txt", 224 | "requirements-dev.txt", 225 | "requirements-typing.txt", 226 | ]; 227 | 228 | assert_cmd_snapshot!(cli() 229 | .arg(&project_path) 230 | .arg("--dev-requirements-file") 231 | .arg("requirements-dev.txt") 232 | .arg("--dev-requirements-file") 233 | .arg("requirements-typing.txt") 234 | .arg("--dry-run"), @r#" 235 | success: true 236 | exit_code: 0 237 | ----- stdout ----- 238 | 239 | ----- stderr ----- 240 | warning: Could not parse the following dependency specification as a PEP 508 one: file:bar 241 | warning: Could not parse the following dependency specification as a PEP 508 one: file:./bar 242 | warning: Could not parse the following dependency specification as a PEP 508 one: git+https://github.com/psf/requests 243 | warning: Could not parse the following dependency specification as a PEP 508 one: git+https://github.com/psf/requests#egg=requests 244 | Migrated pyproject.toml: 245 | [project] 246 | name = "" 247 | version = "0.0.1" 248 | dependencies = [ 249 | "arrow==1.3.0", 250 | "httpx[cli]==0.28.1", 251 | "uvicorn @ git+https://github.com/encode/uvicorn", 252 | "requests==2.32.3", 253 | ] 254 | 255 | [dependency-groups] 256 | dev = [ 257 | "pytest==8.3.4", 258 | "ruff==0.8.4", 259 | "mypy==1.14.1", 260 | "types-jsonschema==4.23.0.20241208", 261 | ] 262 | 263 | [tool.uv] 264 | package = false 265 | "#); 266 | 267 | // Assert that previous package manager files have not been removed. 268 | for file in requirements_files { 269 | assert!(project_path.join(file).exists()); 270 | } 271 | 272 | // Assert that `pyproject.toml` was not created. 273 | assert!(!project_path.join("pyproject.toml").exists()); 274 | 275 | // Assert that `uv.lock` file was not generated. 276 | assert!(!project_path.join("uv.lock").exists()); 277 | } 278 | 279 | #[test] 280 | fn test_preserves_existing_project() { 281 | let project_path = Path::new(FIXTURES_PATH).join("existing_project"); 282 | 283 | assert_cmd_snapshot!(cli().arg(&project_path).arg("--dry-run"), @r###" 284 | success: true 285 | exit_code: 0 286 | ----- stdout ----- 287 | 288 | ----- stderr ----- 289 | Migrated pyproject.toml: 290 | [project] 291 | name = "foobar" 292 | version = "1.0.0" 293 | requires-python = ">=3.13" 294 | dependencies = [ 295 | "arrow==1.3.0", 296 | "httpx[cli]==0.28.1", 297 | "uvicorn @ git+https://github.com/encode/uvicorn", 298 | ] 299 | 300 | [tool.uv] 301 | package = false 302 | "###); 303 | } 304 | 305 | #[test] 306 | fn test_replaces_existing_project() { 307 | let project_path = Path::new(FIXTURES_PATH).join("existing_project"); 308 | 309 | assert_cmd_snapshot!(cli() 310 | .arg(&project_path) 311 | .arg("--dry-run") 312 | .arg("--replace-project-section"), @r###" 313 | success: true 314 | exit_code: 0 315 | ----- stdout ----- 316 | 317 | ----- stderr ----- 318 | Migrated pyproject.toml: 319 | [project] 320 | name = "" 321 | version = "0.0.1" 322 | dependencies = [ 323 | "arrow==1.3.0", 324 | "httpx[cli]==0.28.1", 325 | "uvicorn @ git+https://github.com/encode/uvicorn", 326 | ] 327 | 328 | [tool.uv] 329 | package = false 330 | "###); 331 | } 332 | -------------------------------------------------------------------------------- /tests/pip_tools.rs: -------------------------------------------------------------------------------- 1 | use crate::common::{LockedPackage, UvLock, apply_lock_filters, cli}; 2 | use insta_cmd::assert_cmd_snapshot; 3 | use std::path::Path; 4 | use std::{env, fs}; 5 | use tempfile::tempdir; 6 | 7 | mod common; 8 | 9 | const FIXTURES_PATH: &str = "tests/fixtures/pip_tools"; 10 | 11 | #[test] 12 | fn test_complete_workflow() { 13 | let fixture_path = Path::new(FIXTURES_PATH).join("with_lock_file"); 14 | let requirements_files = [ 15 | "requirements.in", 16 | "requirements.txt", 17 | "requirements-dev.in", 18 | "requirements-dev.txt", 19 | "requirements-typing.in", 20 | "requirements-typing.txt", 21 | ]; 22 | 23 | let tmp_dir = tempdir().unwrap(); 24 | let project_path = tmp_dir.path(); 25 | 26 | for file in requirements_files { 27 | fs::copy(fixture_path.join(file), project_path.join(file)).unwrap(); 28 | } 29 | 30 | apply_lock_filters!(); 31 | assert_cmd_snapshot!(cli() 32 | .arg(project_path) 33 | .arg("--dev-requirements-file") 34 | .arg("requirements-dev.in") 35 | .arg("--dev-requirements-file") 36 | .arg("requirements-typing.in"), @r###" 37 | success: true 38 | exit_code: 0 39 | ----- stdout ----- 40 | 41 | ----- stderr ----- 42 | Locking dependencies with "uv lock"... 43 | Using [PYTHON_INTERPRETER] 44 | warning: No `requires-python` value found in the workspace. Defaulting to `[PYTHON_VERSION]`. 45 | Resolved [PACKAGES] packages in [TIME] 46 | Locking dependencies with "uv lock" again to remove constraints... 47 | Using [PYTHON_INTERPRETER] 48 | warning: No `requires-python` value found in the workspace. Defaulting to `[PYTHON_VERSION]`. 49 | Resolved [PACKAGES] packages in [TIME] 50 | Successfully migrated project from pip-tools to uv! 51 | "###); 52 | 53 | insta::assert_snapshot!(fs::read_to_string(project_path.join("pyproject.toml")).unwrap(), @r###" 54 | [project] 55 | name = "" 56 | version = "0.0.1" 57 | dependencies = ["arrow>=1.2.3"] 58 | 59 | [dependency-groups] 60 | dev = [ 61 | "factory-boy>=3.2.1", 62 | "mypy>=1.13.0", 63 | ] 64 | 65 | [tool.uv] 66 | package = false 67 | "###); 68 | 69 | let uv_lock = toml::from_str::( 70 | fs::read_to_string(project_path.join("uv.lock")) 71 | .unwrap() 72 | .as_str(), 73 | ) 74 | .unwrap(); 75 | 76 | // Assert that locked versions in `uv.lock` match what was in requirements files. 77 | let uv_lock_packages = uv_lock.package.unwrap(); 78 | let expected_locked_packages = Vec::from([ 79 | LockedPackage { 80 | name: String::new(), 81 | version: "0.0.1".to_string(), 82 | }, 83 | LockedPackage { 84 | name: "arrow".to_string(), 85 | version: "1.2.3".to_string(), 86 | }, 87 | LockedPackage { 88 | name: "factory-boy".to_string(), 89 | version: "3.2.1".to_string(), 90 | }, 91 | LockedPackage { 92 | name: "faker".to_string(), 93 | version: "33.1.0".to_string(), 94 | }, 95 | LockedPackage { 96 | name: "mypy".to_string(), 97 | version: "1.13.0".to_string(), 98 | }, 99 | LockedPackage { 100 | name: "mypy-extensions".to_string(), 101 | version: "1.0.0".to_string(), 102 | }, 103 | LockedPackage { 104 | name: "python-dateutil".to_string(), 105 | version: "2.7.0".to_string(), 106 | }, 107 | LockedPackage { 108 | name: "six".to_string(), 109 | version: "1.15.0".to_string(), 110 | }, 111 | LockedPackage { 112 | name: "typing-extensions".to_string(), 113 | version: "4.6.0".to_string(), 114 | }, 115 | ]); 116 | for package in expected_locked_packages { 117 | assert!(uv_lock_packages.contains(&package)); 118 | } 119 | 120 | // Assert that previous package manager files are correctly removed. 121 | for file in requirements_files { 122 | assert!(!project_path.join(file).exists()); 123 | } 124 | } 125 | 126 | #[test] 127 | fn test_ignore_locked_versions() { 128 | let fixture_path = Path::new(FIXTURES_PATH).join("with_lock_file"); 129 | let requirements_files = [ 130 | "requirements.in", 131 | "requirements.txt", 132 | "requirements-dev.in", 133 | "requirements-dev.txt", 134 | "requirements-typing.in", 135 | "requirements-typing.txt", 136 | ]; 137 | 138 | let tmp_dir = tempdir().unwrap(); 139 | let project_path = tmp_dir.path(); 140 | 141 | for file in requirements_files { 142 | fs::copy(fixture_path.join(file), project_path.join(file)).unwrap(); 143 | } 144 | 145 | apply_lock_filters!(); 146 | assert_cmd_snapshot!(cli() 147 | .arg(project_path) 148 | .arg("--dev-requirements-file") 149 | .arg("requirements-dev.in") 150 | .arg("--dev-requirements-file") 151 | .arg("requirements-typing.in") 152 | .arg("--ignore-locked-versions"), @r###" 153 | success: true 154 | exit_code: 0 155 | ----- stdout ----- 156 | 157 | ----- stderr ----- 158 | Locking dependencies with "uv lock"... 159 | Using [PYTHON_INTERPRETER] 160 | warning: No `requires-python` value found in the workspace. Defaulting to `[PYTHON_VERSION]`. 161 | Resolved [PACKAGES] packages in [TIME] 162 | Successfully migrated project from pip-tools to uv! 163 | "###); 164 | 165 | insta::assert_snapshot!(fs::read_to_string(project_path.join("pyproject.toml")).unwrap(), @r###" 166 | [project] 167 | name = "" 168 | version = "0.0.1" 169 | dependencies = ["arrow>=1.2.3"] 170 | 171 | [dependency-groups] 172 | dev = [ 173 | "factory-boy>=3.2.1", 174 | "mypy>=1.13.0", 175 | ] 176 | 177 | [tool.uv] 178 | package = false 179 | "###); 180 | 181 | let uv_lock = toml::from_str::( 182 | fs::read_to_string(project_path.join("uv.lock")) 183 | .unwrap() 184 | .as_str(), 185 | ) 186 | .unwrap(); 187 | 188 | let mut arrow: Option = None; 189 | let mut typing_extensions: Option = None; 190 | for package in uv_lock.package.unwrap() { 191 | if package.name == "arrow" { 192 | arrow = Some(package); 193 | } else if package.name == "typing-extensions" { 194 | typing_extensions = Some(package); 195 | } 196 | } 197 | 198 | // Assert that locked versions are different that what was in `poetry.lock`. 199 | assert_ne!(arrow.unwrap().version, "1.2.3"); 200 | assert_ne!(typing_extensions.unwrap().version, "4.6.0"); 201 | 202 | // Assert that previous package manager files are correctly removed. 203 | for file in requirements_files { 204 | assert!(!project_path.join(file).exists()); 205 | } 206 | } 207 | 208 | #[test] 209 | fn test_keep_current_data() { 210 | let fixture_path = Path::new(FIXTURES_PATH).join("with_lock_file"); 211 | let requirements_files = [ 212 | "requirements.in", 213 | "requirements.txt", 214 | "requirements-dev.in", 215 | "requirements-dev.txt", 216 | "requirements-typing.in", 217 | "requirements-typing.txt", 218 | ]; 219 | 220 | let tmp_dir = tempdir().unwrap(); 221 | let project_path = tmp_dir.path(); 222 | 223 | for file in requirements_files { 224 | fs::copy(fixture_path.join(file), project_path.join(file)).unwrap(); 225 | } 226 | 227 | apply_lock_filters!(); 228 | assert_cmd_snapshot!(cli() 229 | .arg(project_path) 230 | .arg("--dev-requirements-file") 231 | .arg("requirements-dev.in") 232 | .arg("--dev-requirements-file") 233 | .arg("requirements-typing.in") 234 | .arg("--keep-current-data"), @r###" 235 | success: true 236 | exit_code: 0 237 | ----- stdout ----- 238 | 239 | ----- stderr ----- 240 | Locking dependencies with "uv lock"... 241 | Using [PYTHON_INTERPRETER] 242 | warning: No `requires-python` value found in the workspace. Defaulting to `[PYTHON_VERSION]`. 243 | Resolved [PACKAGES] packages in [TIME] 244 | Locking dependencies with "uv lock" again to remove constraints... 245 | Using [PYTHON_INTERPRETER] 246 | warning: No `requires-python` value found in the workspace. Defaulting to `[PYTHON_VERSION]`. 247 | Resolved [PACKAGES] packages in [TIME] 248 | Successfully migrated project from pip-tools to uv! 249 | "###); 250 | 251 | insta::assert_snapshot!(fs::read_to_string(project_path.join("pyproject.toml")).unwrap(), @r###" 252 | [project] 253 | name = "" 254 | version = "0.0.1" 255 | dependencies = ["arrow>=1.2.3"] 256 | 257 | [dependency-groups] 258 | dev = [ 259 | "factory-boy>=3.2.1", 260 | "mypy>=1.13.0", 261 | ] 262 | 263 | [tool.uv] 264 | package = false 265 | "###); 266 | 267 | // Assert that previous package manager files have not been removed. 268 | for file in requirements_files { 269 | assert!(project_path.join(file).exists()); 270 | } 271 | } 272 | 273 | #[test] 274 | fn test_skip_lock() { 275 | let fixture_path = Path::new(FIXTURES_PATH).join("with_lock_file"); 276 | let requirements_files = [ 277 | "requirements.in", 278 | "requirements.txt", 279 | "requirements-dev.in", 280 | "requirements-dev.txt", 281 | "requirements-typing.in", 282 | "requirements-typing.txt", 283 | ]; 284 | 285 | let tmp_dir = tempdir().unwrap(); 286 | let project_path = tmp_dir.path(); 287 | 288 | for file in requirements_files { 289 | fs::copy(fixture_path.join(file), project_path.join(file)).unwrap(); 290 | } 291 | 292 | assert_cmd_snapshot!(cli() 293 | .arg(project_path) 294 | .arg("--dev-requirements-file") 295 | .arg("requirements-dev.in") 296 | .arg("--dev-requirements-file") 297 | .arg("requirements-typing.in") 298 | .arg("--skip-lock"), @r###" 299 | success: true 300 | exit_code: 0 301 | ----- stdout ----- 302 | 303 | ----- stderr ----- 304 | Successfully migrated project from pip-tools to uv! 305 | "###); 306 | 307 | insta::assert_snapshot!(fs::read_to_string(project_path.join("pyproject.toml")).unwrap(), @r###" 308 | [project] 309 | name = "" 310 | version = "0.0.1" 311 | dependencies = ["arrow>=1.2.3"] 312 | 313 | [dependency-groups] 314 | dev = [ 315 | "factory-boy>=3.2.1", 316 | "mypy>=1.13.0", 317 | ] 318 | 319 | [tool.uv] 320 | package = false 321 | "###); 322 | 323 | // Assert that previous package manager files are correctly removed. 324 | for file in requirements_files { 325 | assert!(!project_path.join(file).exists()); 326 | } 327 | 328 | // Assert that `uv.lock` file was not generated. 329 | assert!(!project_path.join("uv.lock").exists()); 330 | } 331 | 332 | #[test] 333 | fn test_skip_lock_full() { 334 | let fixture_path = Path::new(FIXTURES_PATH).join("full"); 335 | let requirements_files = [ 336 | "requirements.in", 337 | "requirements.txt", 338 | "requirements-dev.in", 339 | "requirements-dev.txt", 340 | "requirements-typing.in", 341 | "requirements-typing.txt", 342 | ]; 343 | 344 | let tmp_dir = tempdir().unwrap(); 345 | let project_path = tmp_dir.path(); 346 | 347 | for file in requirements_files { 348 | fs::copy(fixture_path.join(file), project_path.join(file)).unwrap(); 349 | } 350 | 351 | assert_cmd_snapshot!(cli() 352 | .arg(project_path) 353 | .arg("--dev-requirements-file") 354 | .arg("requirements-dev.in") 355 | .arg("--dev-requirements-file") 356 | .arg("requirements-typing.in") 357 | .arg("--skip-lock"), @r###" 358 | success: true 359 | exit_code: 0 360 | ----- stdout ----- 361 | 362 | ----- stderr ----- 363 | Successfully migrated project from pip-tools to uv! 364 | "###); 365 | 366 | insta::assert_snapshot!(fs::read_to_string(project_path.join("pyproject.toml")).unwrap(), @r###" 367 | [project] 368 | name = "" 369 | version = "0.0.1" 370 | dependencies = [ 371 | "arrow", 372 | "httpx[cli,zstd]==0.28.1", 373 | "uvicorn @ git+https://github.com/encode/uvicorn", 374 | ] 375 | 376 | [dependency-groups] 377 | dev = [ 378 | "pytest>=8.3.4", 379 | "ruff==0.8.4", 380 | "mypy==1.14.1", 381 | "types-jsonschema==4.23.0.20241208", 382 | ] 383 | 384 | [tool.uv] 385 | package = false 386 | "###); 387 | 388 | // Assert that previous package manager files are correctly removed. 389 | for file in requirements_files { 390 | assert!(!project_path.join(file).exists()); 391 | } 392 | 393 | // Assert that `uv.lock` file was not generated. 394 | assert!(!project_path.join("uv.lock").exists()); 395 | } 396 | 397 | #[test] 398 | fn test_dry_run() { 399 | let project_path = Path::new(FIXTURES_PATH).join("with_lock_file"); 400 | let requirements_files = [ 401 | "requirements.in", 402 | "requirements.txt", 403 | "requirements-dev.in", 404 | "requirements-dev.txt", 405 | "requirements-typing.in", 406 | "requirements-typing.txt", 407 | ]; 408 | 409 | assert_cmd_snapshot!(cli() 410 | .arg(&project_path) 411 | .arg("--dev-requirements-file") 412 | .arg("requirements-dev.in") 413 | .arg("--dev-requirements-file") 414 | .arg("requirements-typing.in") 415 | .arg("--dry-run"), @r###" 416 | success: true 417 | exit_code: 0 418 | ----- stdout ----- 419 | 420 | ----- stderr ----- 421 | Migrated pyproject.toml: 422 | [project] 423 | name = "" 424 | version = "0.0.1" 425 | dependencies = ["arrow>=1.2.3"] 426 | 427 | [dependency-groups] 428 | dev = [ 429 | "factory-boy>=3.2.1", 430 | "mypy>=1.13.0", 431 | ] 432 | 433 | [tool.uv] 434 | package = false 435 | "###); 436 | 437 | // Assert that previous package manager files have not been removed. 438 | for file in requirements_files { 439 | assert!(project_path.join(file).exists()); 440 | } 441 | 442 | // Assert that `pyproject.toml` was not created. 443 | assert!(!project_path.join("pyproject.toml").exists()); 444 | 445 | // Assert that `uv.lock` file was not generated. 446 | assert!(!project_path.join("uv.lock").exists()); 447 | } 448 | 449 | #[test] 450 | fn test_preserves_existing_project() { 451 | let project_path = Path::new(FIXTURES_PATH).join("existing_project"); 452 | 453 | assert_cmd_snapshot!(cli().arg(&project_path).arg("--dry-run"), @r###" 454 | success: true 455 | exit_code: 0 456 | ----- stdout ----- 457 | 458 | ----- stderr ----- 459 | Migrated pyproject.toml: 460 | [project] 461 | name = "foobar" 462 | version = "1.0.0" 463 | requires-python = ">=3.13" 464 | dependencies = ["arrow>=1.2.3"] 465 | 466 | [tool.uv] 467 | package = false 468 | "###); 469 | } 470 | 471 | #[test] 472 | fn test_replaces_existing_project() { 473 | let project_path = Path::new(FIXTURES_PATH).join("existing_project"); 474 | 475 | assert_cmd_snapshot!(cli() 476 | .arg(&project_path) 477 | .arg("--dry-run") 478 | .arg("--replace-project-section"), @r###" 479 | success: true 480 | exit_code: 0 481 | ----- stdout ----- 482 | 483 | ----- stderr ----- 484 | Migrated pyproject.toml: 485 | [project] 486 | name = "" 487 | version = "0.0.1" 488 | dependencies = ["arrow>=1.2.3"] 489 | 490 | [tool.uv] 491 | package = false 492 | "###); 493 | } 494 | --------------------------------------------------------------------------------