├── .github └── workflows │ ├── main.yaml │ └── release.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── _index.md ├── poetry.lock ├── pyproject.toml ├── src └── poetry_plugin_export │ ├── __init__.py │ ├── command.py │ ├── exporter.py │ ├── plugins.py │ └── walker.py └── tests ├── __init__.py ├── command ├── __init__.py ├── conftest.py └── test_command_export.py ├── conftest.py ├── fixtures ├── distributions │ ├── demo-0.1.0-py2.py3-none-any.whl │ └── demo-0.1.0.tar.gz ├── project_with_nested_local │ ├── bar │ │ └── pyproject.toml │ ├── foo │ │ └── pyproject.toml │ ├── pyproject.toml │ └── quix │ │ └── pyproject.toml ├── project_with_setup │ ├── my_package │ │ └── __init__.py │ ├── project_with_setup.egg-info │ │ ├── PKG-INFO │ │ ├── SOURCES.txt │ │ ├── dependency_links.txt │ │ ├── requires.txt │ │ └── top_level.txt │ └── setup.py ├── sample_project │ ├── README.rst │ └── pyproject.toml └── simple_project │ ├── README.rst │ ├── dist │ ├── simple-project-1.2.3.tar.gz │ └── simple_project-1.2.3-py2.py3-none-any.whl │ ├── pyproject.toml │ └── simple_project │ └── __init__.py ├── helpers.py ├── markers.py ├── test_exporter.py ├── test_walker.py └── types.py /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: {} 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: tests-${{ github.head_ref || github.ref }} 11 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} 12 | 13 | permissions: {} 14 | 15 | jobs: 16 | tests: 17 | name: ${{ matrix.os }} / ${{ matrix.python-version }} ${{ matrix.suffix }} 18 | runs-on: ${{ matrix.image }} 19 | strategy: 20 | matrix: 21 | os: [Ubuntu, macOS, Windows] 22 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 23 | include: 24 | - os: Ubuntu 25 | image: ubuntu-latest 26 | - os: Windows 27 | image: windows-2022 28 | - os: macOS 29 | image: macos-14 30 | fail-fast: false 31 | defaults: 32 | run: 33 | shell: bash 34 | steps: 35 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 36 | with: 37 | persist-credentials: false 38 | 39 | - name: Set up Python ${{ matrix.python-version }} 40 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | allow-prereleases: true 44 | 45 | - name: Get full Python version 46 | id: full-python-version 47 | run: echo "version=$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))")" >> $GITHUB_OUTPUT 48 | 49 | - name: Bootstrap poetry 50 | run: | 51 | curl -sL https://install.python-poetry.org | python - -y ${{ matrix.bootstrap-args }} 52 | 53 | - name: Update PATH 54 | if: ${{ matrix.os != 'Windows' }} 55 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 56 | 57 | - name: Update Path for Windows 58 | if: ${{ matrix.os == 'Windows' }} 59 | run: echo "$APPDATA\Python\Scripts" >> $GITHUB_PATH 60 | 61 | - name: Configure poetry 62 | run: poetry config virtualenvs.in-project true 63 | 64 | - name: Set up cache 65 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 66 | id: cache 67 | with: 68 | path: .venv 69 | key: venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} 70 | 71 | - name: Ensure cache is healthy 72 | if: steps.cache.outputs.cache-hit == 'true' 73 | run: timeout 10s poetry run pip --version || rm -rf .venv 74 | 75 | - name: Install dependencies 76 | run: poetry install --with github-actions 77 | 78 | - name: Run mypy 79 | run: poetry run mypy 80 | 81 | - name: Run pytest 82 | run: poetry run pytest -v 83 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: {} 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 15 | with: 16 | persist-credentials: false 17 | 18 | - run: pipx run build 19 | 20 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 21 | with: 22 | name: distfiles 23 | path: dist/ 24 | if-no-files-found: error 25 | 26 | upload-github: 27 | name: Upload (GitHub) 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: write 31 | needs: build 32 | steps: 33 | # We need to be in a git repo for gh to work. 34 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 35 | with: 36 | persist-credentials: false 37 | 38 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 39 | with: 40 | name: distfiles 41 | path: dist/ 42 | 43 | - run: gh release upload "${TAG_NAME}" dist/*.{tar.gz,whl} 44 | env: 45 | GH_TOKEN: ${{ github.token }} 46 | TAG_NAME: ${{ github.event.release.tag_name }} 47 | 48 | upload-pypi: 49 | name: Upload (PyPI) 50 | runs-on: ubuntu-latest 51 | environment: 52 | name: pypi 53 | url: https://pypi.org/project/poetry-plugin-export/ 54 | permissions: 55 | id-token: write 56 | needs: build 57 | steps: 58 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 59 | with: 60 | name: distfiles 61 | path: dist/ 62 | 63 | - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 64 | with: 65 | print-hash: true 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | # Packages 4 | *.egg 5 | !/tests/**/*.egg 6 | /*.egg-info 7 | /dist/* 8 | build 9 | _build 10 | .cache 11 | *.so 12 | 13 | # Installer logs 14 | pip-log.txt 15 | 16 | # Unit test / coverage reports 17 | .coverage 18 | .tox 19 | .pytest_cache 20 | 21 | .DS_Store 22 | .idea/* 23 | .python-version 24 | .vscode/* 25 | 26 | /test.py 27 | /test_*.* 28 | 29 | /setup.cfg 30 | MANIFEST.in 31 | /setup.py 32 | /docs/site/* 33 | /tests/fixtures/simple_project/setup.py 34 | .mypy_cache 35 | 36 | .venv 37 | /releases/* 38 | pip-wheel-metadata 39 | /poetry.toml 40 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_prs: false 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | exclude: ^.*\.egg-info/ 11 | - id: check-merge-conflict 12 | - id: check-case-conflict 13 | - id: check-json 14 | - id: check-toml 15 | - id: check-yaml 16 | - id: pretty-format-json 17 | args: [--autofix, --no-ensure-ascii, --no-sort-keys] 18 | - id: check-ast 19 | - id: debug-statements 20 | - id: check-docstring-first 21 | 22 | - repo: https://github.com/astral-sh/ruff-pre-commit 23 | rev: v0.11.6 24 | hooks: 25 | - id: ruff 26 | - id: ruff-format 27 | 28 | - repo: https://github.com/woodruffw/zizmor-pre-commit 29 | rev: v1.6.0 30 | hooks: 31 | - id: zizmor 32 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: poetry-export 2 | name: poetry-export 3 | description: run poetry export to sync lock file with requirements.txt 4 | entry: poetry export 5 | language: python 6 | language_version: python3 7 | pass_filenames: false 8 | files: ^(.*/)?poetry\.lock$ 9 | args: ["-f", "requirements.txt", "-o", "requirements.txt"] 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [1.9.0] - 2025-01-12 4 | 5 | ### Added 6 | 7 | - Add an `--all-groups` option to export dependencies from all groups ([#294](https://github.com/python-poetry/poetry-plugin-export/pull/294)). 8 | 9 | ### Changed 10 | 11 | - Drop support for Python 3.8 ([#300](https://github.com/python-poetry/poetry-plugin-export/pull/300)). 12 | - Clarify the help text of `--with` and `--only` and deprecate `--without` ([#212](https://github.com/python-poetry/poetry-plugin-export/pull/212)). 13 | - Fail if the `poetry.lock` file is not consistent with the `pyproject.toml` file ([#310](https://github.com/python-poetry/poetry-plugin-export/pull/310)). 14 | 15 | ### Fixed 16 | 17 | - Fix an issue where the export failed with the message `"dependency walk failed"`. 18 | This fix requires a `poetry.lock` file created with Poetry 2.x ([#286](https://github.com/python-poetry/poetry-plugin-export/pull/286)). 19 | - Fix an issue where the `pre-commit` hook regex matched wrong files ([#285](https://github.com/python-poetry/poetry-plugin-export/pull/285)). 20 | 21 | 22 | ## [1.8.0] - 2024-05-11 23 | 24 | ### Changed 25 | 26 | - Relax the constraint on `poetry` and `poetry-core` to allow (future) `2.*` versions ([#280](https://github.com/python-poetry/poetry-plugin-export/pull/280)). 27 | 28 | ### Fixed 29 | 30 | - Fix an issue where editable installs where not exported correctly ([#258](https://github.com/python-poetry/poetry-plugin-export/pull/258)). 31 | 32 | 33 | ## [1.7.1] - 2024-03-19 34 | 35 | ### Changed 36 | 37 | - Export `--index-url` before `--extra-index-url` to work around a pip bug ([#270](https://github.com/python-poetry/poetry-plugin-export/pull/270)). 38 | 39 | ### Fixed 40 | 41 | - Fix an issue where the source with the highest priority was exported with `--index-url` despite PyPI being among the sources ([#270](https://github.com/python-poetry/poetry-plugin-export/pull/270)). 42 | 43 | 44 | ## [1.7.0] - 2024-03-14 45 | 46 | ### Changed 47 | 48 | - Bump minimum required poetry version to 1.8.0 ([#263](https://github.com/python-poetry/poetry-plugin-export/pull/263)). 49 | 50 | ### Fixed 51 | 52 | - Fix an issue where all sources were exported with `--extra-index-url` even though PyPI was deactivated ([#263](https://github.com/python-poetry/poetry-plugin-export/pull/263)). 53 | 54 | 55 | ## [1.6.0] - 2023-10-30 56 | 57 | ### Added 58 | 59 | - Add an `--all-extras` option ([#241](https://github.com/python-poetry/poetry-plugin-export/pull/241)). 60 | 61 | ### Fixed 62 | 63 | - Fix an issue where git dependencies are exported with the branch name instead of the resolved commit hash ([#213](https://github.com/python-poetry/poetry-plugin-export/pull/213)). 64 | 65 | 66 | ## [1.5.0] - 2023-08-20 67 | 68 | ### Changed 69 | 70 | - Drop support for Python 3.7 ([#189](https://github.com/python-poetry/poetry-plugin-export/pull/189)). 71 | - Improve warning when the lock file is not consistent with pyproject.toml ([#215](https://github.com/python-poetry/poetry-plugin-export/pull/215)). 72 | 73 | ### Fixed 74 | 75 | - Fix an issue where markers for dependencies required by an extra were not generated correctly ([#209](https://github.com/python-poetry/poetry-plugin-export/pull/209)). 76 | 77 | 78 | ## [1.4.0] - 2023-05-29 79 | 80 | ### Changed 81 | 82 | - Bump minimum required poetry version to 1.5.0 ([#196](https://github.com/python-poetry/poetry-plugin-export/pull/196)). 83 | 84 | ### Fixed 85 | 86 | - Fix an issue where `--extra-index-url` and `--trusted-host` was not generated for sources with priority `explicit` ([#205](https://github.com/python-poetry/poetry-plugin-export/pull/205)). 87 | 88 | 89 | ## [1.3.1] - 2023-04-17 90 | 91 | This release mainly fixes test suite compatibility with upcoming Poetry releases. 92 | 93 | ### Changed 94 | 95 | - Improve error message in some cases when the dependency walk fails ([#184](https://github.com/python-poetry/poetry-plugin-export/pull/184)). 96 | 97 | 98 | ## [1.3.0] - 2023-01-30 99 | 100 | ### Changed 101 | 102 | - Drop some compatibility code and bump minimum required poetry version to 1.3.0 ([#167](https://github.com/python-poetry/poetry-plugin-export/pull/167)). 103 | 104 | ### Fixed 105 | 106 | - Fix an issue where the export failed if there was a circular dependency on the root package ([#118](https://github.com/python-poetry/poetry-plugin-export/pull/118)). 107 | 108 | 109 | ## [1.2.0] - 2022-11-05 110 | 111 | ### Changed 112 | 113 | - Drop some compatibility code and bump minimum required poetry version to 1.2.2 ([#143](https://github.com/python-poetry/poetry-plugin-export/pull/143)). 114 | - Ensure compatibility with upcoming Poetry releases ([#151](https://github.com/python-poetry/poetry-plugin-export/pull/151)). 115 | 116 | 117 | ## [1.1.2] - 2022-10-09 118 | 119 | ### Fixed 120 | 121 | - Fix an issue where exporting a `constraints.txt` file fails if an editable dependency is locked ([#140](https://github.com/python-poetry/poetry-plugin-export/pull/140)). 122 | 123 | 124 | ## [1.1.1] - 2022-10-03 125 | 126 | This release fixes test suite compatibility with upcoming Poetry releases. No functional changes. 127 | 128 | 129 | ## [1.1.0] - 2022-10-01 130 | 131 | ### Added 132 | 133 | - Add support for exporting `constraints.txt` files ([#128](https://github.com/python-poetry/poetry-plugin-export/pull/128)). 134 | 135 | ### Fixed 136 | 137 | - Fix an issue where a relative path passed via `-o` was not interpreted relative to the current working directory ([#130](https://github.com/python-poetry/poetry-plugin-export/pull/130)). 138 | - Fix an issue where the names of extras were not normalized according to PEP 685 ([#123](https://github.com/python-poetry/poetry-plugin-export/pull/123)). 139 | 140 | 141 | ## [1.0.7] - 2022-09-13 142 | 143 | ### Added 144 | 145 | - Add support for multiple extras in a single flag ([#103](https://github.com/python-poetry/poetry-plugin-export/pull/103)). 146 | - Add `homepage` and `repository` to metadata ([#113](https://github.com/python-poetry/poetry-plugin-export/pull/113)). 147 | - Add a `poetry-export` pre-commit hook ([#85](https://github.com/python-poetry/poetry-plugin-export/pull/85)). 148 | 149 | ### Fixed 150 | 151 | - Fix an issue where a virtual environment was created unnecessarily when running `poetry export` (requires poetry 1.2.1) ([#106](https://github.com/python-poetry/poetry-plugin-export/pull/106)). 152 | - Fix an issue where package sources were not taken into account ([#111](https://github.com/python-poetry/poetry-plugin-export/pull/111)). 153 | - Fix an issue where trying to export with extras that do not exist results in empty output ([#103](https://github.com/python-poetry/poetry-plugin-export/pull/103)). 154 | - Fix an issue where exporting a dependency on a package with a non-existent extra fails ([#109](https://github.com/python-poetry/poetry-plugin-export/pull/109)). 155 | - Fix an issue where only one of `--index-url` and `--extra-index-url` were exported ([#117](https://github.com/python-poetry/poetry-plugin-export/pull/117)). 156 | 157 | 158 | ## [1.0.6] - 2022-08-07 159 | 160 | ### Fixed 161 | 162 | - Fixed an issue the markers of exported dependencies overlapped. [#94](https://github.com/python-poetry/poetry-plugin-export/pull/94) 163 | 164 | 165 | ## [1.0.5] - 2022-07-12 166 | 167 | ### Added 168 | 169 | - Added LICENSE file. [#81](https://github.com/python-poetry/poetry-plugin-export/pull/81) 170 | 171 | 172 | ## [1.0.4] - 2022-05-26 173 | 174 | ### Fixed 175 | 176 | - Fixed an issue where the exported dependencies did not list their active extras. [#65](https://github.com/python-poetry/poetry-plugin-export/pull/65) 177 | 178 | 179 | ## [1.0.3] - 2022-05-23 180 | 181 | This release fixes test suite compatibility with upcoming Poetry releases. No functional changes. 182 | 183 | 184 | ## [1.0.2] - 2022-05-10 185 | 186 | ### Fixed 187 | 188 | - Fixed an issue where the exported hashes were not sorted. [#54](https://github.com/python-poetry/poetry-plugin-export/pull/54) 189 | 190 | ### Changes 191 | 192 | - The implicit dependency group was renamed from "default" to "main". (Requires poetry-core > 1.1.0a7 to take effect.) [#52](https://github.com/python-poetry/poetry-plugin-export/pull/52) 193 | 194 | 195 | ## [1.0.1] - 2022-04-11 196 | 197 | ### Fixed 198 | 199 | - Fixed a regression where export incorrectly always exported default group only. [#50](https://github.com/python-poetry/poetry-plugin-export/pull/50) 200 | 201 | 202 | ## [1.0.0] - 2022-04-05 203 | 204 | ### Fixed 205 | 206 | - Fixed an issue with dependency selection when duplicates exist with different markers. [poetry#4932](https://github.com/python-poetry/poetry/pull/4932) 207 | - Fixed an issue where unconstrained duplicate dependencies are listed with conditional on python version. [poetry#5141](https://github.com/python-poetry/poetry/issues/5141) 208 | 209 | ### Changes 210 | 211 | - Export command now constraints all exported dependencies with the root project's python version constraint. [poetry#5156](https://github.com/python-poetry/poetry/pull/5156) 212 | 213 | ### Added 214 | 215 | - Added support for `--without-urls` option. [poetry#4763](https://github.com/python-poetry/poetry/pull/4763) 216 | 217 | 218 | ## [0.2.1] - 2021-11-24 219 | 220 | ### Fixed 221 | 222 | - Fixed the output for packages with markers. [#13](https://github.com/python-poetry/poetry-plugin-export/pull/13) 223 | - Check the existence of the `export` command before attempting to delete it. [#18](https://github.com/python-poetry/poetry-plugin-export/pull/18) 224 | 225 | 226 | ## [0.2.0] - 2021-09-13 227 | 228 | ### Added 229 | 230 | - Added support for dependency groups. [#6](https://github.com/python-poetry/poetry-plugin-export/pull/6) 231 | 232 | 233 | [Unreleased]: https://github.com/python-poetry/poetry-plugin-export/compare/1.9.0...main 234 | [1.9.0]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.9.0 235 | [1.8.0]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.8.0 236 | [1.7.1]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.7.1 237 | [1.7.0]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.7.0 238 | [1.6.0]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.6.0 239 | [1.5.0]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.5.0 240 | [1.4.0]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.4.0 241 | [1.3.1]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.3.1 242 | [1.3.0]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.3.0 243 | [1.2.0]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.2.0 244 | [1.1.2]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.1.2 245 | [1.1.1]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.1.1 246 | [1.1.0]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.1.0 247 | [1.0.7]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.0.7 248 | [1.0.6]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.0.6 249 | [1.0.5]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.0.5 250 | [1.0.4]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.0.4 251 | [1.0.3]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.0.3 252 | [1.0.2]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.0.2 253 | [1.0.1]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.0.1 254 | [1.0.0]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/1.0.0 255 | [0.2.1]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/0.2.1 256 | [0.2.0]: https://github.com/python-poetry/poetry-plugin-export/releases/tag/0.2.0 257 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Sébastien Eustace 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Poetry Plugin: Export 2 | 3 | [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/) 4 | 5 | This package is a plugin that allows the export of locked packages to various formats. 6 | 7 | **Note**: For now, only the `constraints.txt` and `requirements.txt` formats are available. 8 | 9 | This plugin provides the same features as the existing `export` command of Poetry which it will eventually replace. 10 | 11 | 12 | ## Installation 13 | 14 | On Poetry 2.0 and newer, the easiest way to add the `export` plugin is to declare it as a required Poetry plugin. 15 | 16 | ```toml 17 | [tool.poetry.requires-plugins] 18 | poetry-plugin-export = ">=1.8" 19 | ``` 20 | 21 | Otherwise, install the plugin via the `self add` command of Poetry. 22 | 23 | ```bash 24 | poetry self add poetry-plugin-export 25 | ``` 26 | 27 | If you used `pipx` to install Poetry you can add the plugin via the `pipx inject` command. 28 | 29 | ```bash 30 | pipx inject poetry poetry-plugin-export 31 | ``` 32 | 33 | Otherwise, if you used `pip` to install Poetry you can add the plugin packages via the `pip install` command. 34 | 35 | ```bash 36 | pip install poetry-plugin-export 37 | ``` 38 | 39 | 40 | ## Usage 41 | 42 | The plugin provides an `export` command to export to the desired format. 43 | 44 | ```bash 45 | poetry export -f requirements.txt --output requirements.txt 46 | ``` 47 | 48 | > [!IMPORTANT] 49 | > When installing an exported `requirements.txt` via `pip`, you should always pass `--no-deps` 50 | > because Poetry has already resolved the dependencies so that all direct and transitive 51 | > requirements are included and it is not necessary to resolve again via `pip`. 52 | > `pip` may even fail to resolve dependencies, especially if `git` dependencies, 53 | > which are exported with their resolved hashes, are included. 54 | 55 | > [!NOTE] 56 | > Only the `constraints.txt` and `requirements.txt` formats are currently supported. 57 | 58 | ### Available options 59 | 60 | * `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `constraints.txt` and `requirements.txt` are supported. 61 | * `--output (-o)`: The name of the output file. If omitted, print to standard output. 62 | * `--with`: The optional and non-optional dependency groups to include. By default, only the main dependencies are included. 63 | * `--only`: The only dependency groups to include. It is possible to exclude the `main` group this way. 64 | * `--without`: The dependency groups to ignore. (**Deprecated**) 65 | * `--default`: Only export the main dependencies. (**Deprecated**) 66 | * `--dev`: Include development dependencies. (**Deprecated**) 67 | * `--extras (-E)`: Extra sets of dependencies to include. 68 | * `--all-extras`: Include all sets of extra dependencies. 69 | * `--all-groups`: Include all dependency groups. 70 | * `--without-hashes`: Exclude hashes from the exported file. 71 | * `--with-credentials`: Include credentials for extra indices. 72 | -------------------------------------------------------------------------------- /docs/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Export plugin" 3 | draft: false 4 | type: docs 5 | layout: single 6 | 7 | menu: 8 | docs: 9 | weight: 1001 10 | --- 11 | 12 | # Export plugin 13 | 14 | The export plugin allows the export of locked packages to various formats. 15 | 16 | {{% note %}} 17 | Only the `constraints.txt` and `requirements.txt` formats are currently supported. 18 | {{% /note %}} 19 | 20 | ## Exporting packages 21 | 22 | The plugin provides an `export` command to export the locked packages to 23 | various formats. 24 | 25 | The default export format is the `requirements.txt` format which is currently 26 | the most compatible one. You can specify a format with the `--format (-f)` option: 27 | 28 | ```bash 29 | poetry export -f requirements.txt 30 | ``` 31 | 32 | By default, the `export` command will export to the standard output. 33 | You can specify a file to export to with the `--output (-o)` option: 34 | 35 | ```bash 36 | poetry export --output requirements.txt 37 | ``` 38 | 39 | Similarly to the [`install`]({{< relref "../cli#install" >}}) command, you can control 40 | which [dependency groups]({{< relref "managing-dependencies#dependency-groups" >}}) 41 | need to be exported. 42 | 43 | If you want to exclude one or more dependency group from the export, you can use 44 | the `--without` option. 45 | 46 | ```bash 47 | poetry export --without test,docs 48 | ``` 49 | 50 | You can also select optional dependency groups with the `--with` option. 51 | 52 | ```bash 53 | poetry export --with test,docs 54 | ``` 55 | 56 | {{% note %}} 57 | The `--dev` option is now deprecated. You should use the `--with dev` notation instead. 58 | {{% /note %}} 59 | 60 | It's also possible to only export specific dependency groups by using the `only` option. 61 | 62 | ```bash 63 | poetry export --only test,docs 64 | ``` 65 | 66 | ### Available options 67 | 68 | * `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `constraints.txt` and `requirements.txt` are supported. 69 | * `--output (-o)`: The name of the output file. If omitted, print to standard output. 70 | * `--with`: The optional and non-optional dependency groups to include. By default, only the main dependencies are included. 71 | * `--only`: The only dependency groups to include. It is possible to exclude the `main` group this way. 72 | * {{< option name="without" deprecated=true >}}The dependency groups to ignore.{{< /option >}} 73 | * {{< option name="default" deprecated=true >}}Only export the main dependencies.{{< /option >}} 74 | * {{< option name="dev" deprecated=true >}}Include development dependencies.{{< /option >}} 75 | * `--extras (-E)`: Extra sets of dependencies to include. 76 | * `--all-extras`: Include all sets of extra dependencies. 77 | * `--all-groups`: Include all dependency groups. 78 | * `--without-hashes`: Exclude hashes from the exported file. 79 | * `--with-credentials`: Include credentials for extra indices. 80 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "poetry-plugin-export" 3 | version = "1.9.0" 4 | description = "Poetry plugin to export the dependencies to various formats" 5 | authors = [{ name = "Sébastien Eustace", email = "sebastien@eustace.io" }] 6 | license = { text = "MIT" } 7 | readme = "README.md" 8 | requires-python = ">=3.9,<4.0" 9 | dependencies = [ 10 | "poetry>=2.1.0,<3.0.0", 11 | "poetry-core>=2.1.0,<3.0.0", 12 | ] 13 | dynamic = ["classifiers"] 14 | 15 | [project.urls] 16 | homepage = "https://python-poetry.org/" 17 | repository = "https://github.com/python-poetry/poetry-plugin-export" 18 | 19 | [project.entry-points."poetry.application.plugin"] 20 | export = "poetry_plugin_export.plugins:ExportApplicationPlugin" 21 | 22 | [tool.poetry] 23 | packages = [ 24 | { include = "poetry_plugin_export", from = "src" } 25 | ] 26 | include = [ 27 | { path = "tests", format = "sdist" } 28 | ] 29 | 30 | [tool.poetry.group.dev.dependencies] 31 | pre-commit = ">=2.18" 32 | pytest = "^8.0" 33 | pytest-cov = "^4.0" 34 | pytest-mock = "^3.9" 35 | pytest-randomly = "^3.12" 36 | pytest-xdist = { version = "^3.1", extras = ["psutil"] } 37 | mypy = ">=0.971" 38 | 39 | # only used in github actions 40 | [tool.poetry.group.github-actions] 41 | optional = true 42 | [tool.poetry.group.github-actions.dependencies] 43 | pytest-github-actions-annotate-failures = "^0.1.7" 44 | 45 | 46 | [tool.ruff] 47 | fix = true 48 | target-version = "py39" 49 | line-length = 88 50 | extend-exclude = [ 51 | "docs/*", 52 | # External to the project's coding standards 53 | "tests/**/fixtures/*", 54 | ] 55 | 56 | [tool.ruff.lint] 57 | unfixable = [ 58 | "ERA", # do not autoremove commented out code 59 | ] 60 | extend-select = [ 61 | "B", # flake8-bugbear 62 | "C4", # flake8-comprehensions 63 | "ERA", # flake8-eradicate/eradicate 64 | "I", # isort 65 | "N", # pep8-naming 66 | "PIE", # flake8-pie 67 | "PGH", # pygrep 68 | "RUF", # ruff checks 69 | "SIM", # flake8-simplify 70 | "TCH", # flake8-type-checking 71 | "TID", # flake8-tidy-imports 72 | "UP", # pyupgrade 73 | ] 74 | 75 | [tool.ruff.lint.flake8-tidy-imports] 76 | ban-relative-imports = "all" 77 | 78 | [tool.ruff.lint.isort] 79 | force-single-line = true 80 | lines-between-types = 1 81 | lines-after-imports = 2 82 | known-first-party = ["poetry_plugin_export"] 83 | required-imports = ["from __future__ import annotations"] 84 | 85 | 86 | [tool.mypy] 87 | namespace_packages = true 88 | show_error_codes = true 89 | enable_error_code = [ 90 | "ignore-without-code", 91 | "redundant-expr", 92 | "truthy-bool", 93 | ] 94 | strict = true 95 | files = ["src", "tests"] 96 | exclude = ["^tests/fixtures/"] 97 | 98 | # use of importlib-metadata backport makes it impossible to satisfy mypy 99 | # without some ignores: but we get a different set of ignores at different 100 | # python versions. 101 | # 102 | # , meanwhile suppress that 103 | # warning. 104 | [[tool.mypy.overrides]] 105 | module = [ 106 | 'poetry_plugin_export', 107 | ] 108 | warn_unused_ignores = false 109 | 110 | 111 | [tool.pytest.ini_options] 112 | addopts = "-n auto" 113 | testpaths = [ 114 | "tests" 115 | ] 116 | 117 | 118 | [build-system] 119 | requires = ["poetry-core>=2.0"] 120 | build-backend = "poetry.core.masonry.api" 121 | -------------------------------------------------------------------------------- /src/poetry_plugin_export/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from poetry.utils._compat import metadata 4 | 5 | 6 | __version__ = metadata.version("poetry-plugin-export") # type: ignore[no-untyped-call] 7 | -------------------------------------------------------------------------------- /src/poetry_plugin_export/command.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | 6 | from cleo.helpers import option 7 | from packaging.utils import NormalizedName 8 | from packaging.utils import canonicalize_name 9 | from poetry.console.commands.group_command import GroupCommand 10 | from poetry.core.packages.dependency_group import MAIN_GROUP 11 | 12 | from poetry_plugin_export.exporter import Exporter 13 | 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterable 17 | 18 | 19 | class ExportCommand(GroupCommand): 20 | name = "export" 21 | description = "Exports the lock file to alternative formats." 22 | 23 | options = [ # noqa: RUF012 24 | option( 25 | "format", 26 | "f", 27 | "Format to export to. Currently, only constraints.txt and" 28 | " requirements.txt are supported.", 29 | flag=False, 30 | default=Exporter.FORMAT_REQUIREMENTS_TXT, 31 | ), 32 | option("output", "o", "The name of the output file.", flag=False), 33 | option("without-hashes", None, "Exclude hashes from the exported file."), 34 | option( 35 | "without-urls", 36 | None, 37 | "Exclude source repository urls from the exported file.", 38 | ), 39 | option( 40 | "dev", 41 | None, 42 | "Include development dependencies. (Deprecated)", 43 | ), 44 | option("all-groups", None, "Include all dependency groups"), 45 | option( 46 | "with", 47 | None, 48 | # note: unlike poetry install, the default excludes non-optional groups 49 | "The optional and non-optional dependency groups to include." 50 | " By default, only the main dependencies are included.", 51 | flag=False, 52 | multiple=True, 53 | ), 54 | option( 55 | "only", 56 | None, 57 | "The only dependency groups to include.", 58 | flag=False, 59 | multiple=True, 60 | ), 61 | option( 62 | "without", 63 | None, 64 | # deprecated: groups are always excluded by default 65 | "The dependency groups to ignore. (Deprecated)", 66 | flag=False, 67 | multiple=True, 68 | ), 69 | option( 70 | "extras", 71 | "E", 72 | "Extra sets of dependencies to include.", 73 | flag=False, 74 | multiple=True, 75 | ), 76 | option("all-extras", None, "Include all sets of extra dependencies."), 77 | option("with-credentials", None, "Include credentials for extra indices."), 78 | ] 79 | 80 | @property 81 | def default_groups(self) -> set[str]: 82 | return {MAIN_GROUP} 83 | 84 | def handle(self) -> int: 85 | fmt = self.option("format") 86 | 87 | if not Exporter.is_format_supported(fmt): 88 | raise ValueError(f"Invalid export format: {fmt}") 89 | 90 | output = self.option("output") 91 | 92 | locker = self.poetry.locker 93 | if not locker.is_locked(): 94 | self.line_error("The lock file does not exist. Locking.") 95 | options = [] 96 | if self.io.is_debug(): 97 | options.append(("-vvv", None)) 98 | elif self.io.is_very_verbose(): 99 | options.append(("-vv", None)) 100 | elif self.io.is_verbose(): 101 | options.append(("-v", None)) 102 | 103 | self.call("lock", " ".join(options)) # type: ignore[arg-type] 104 | 105 | if not locker.is_fresh(): 106 | self.line_error( 107 | "" 108 | "pyproject.toml changed significantly since poetry.lock was last" 109 | " generated. Run `poetry lock` to fix the lock file." 110 | "" 111 | ) 112 | return 1 113 | 114 | if self.option("extras") and self.option("all-extras"): 115 | self.line_error( 116 | "You cannot specify explicit" 117 | " `--extras` while exporting" 118 | " using `--all-extras`." 119 | ) 120 | return 1 121 | 122 | extras: Iterable[NormalizedName] 123 | if self.option("all-extras"): 124 | extras = self.poetry.package.extras.keys() 125 | else: 126 | extras = { 127 | canonicalize_name(extra) 128 | for extra_opt in self.option("extras") 129 | for extra in extra_opt.split() 130 | } 131 | invalid_extras = extras - self.poetry.package.extras.keys() 132 | if invalid_extras: 133 | raise ValueError( 134 | f"Extra [{', '.join(sorted(invalid_extras))}] is not specified." 135 | ) 136 | 137 | if ( 138 | self.option("with") or self.option("without") or self.option("only") 139 | ) and self.option("all-groups"): 140 | self.line_error( 141 | "You cannot specify explicit" 142 | " `--with`, " 143 | "`--without`, " 144 | "or `--only` " 145 | "while exporting using `--all-groups`." 146 | ) 147 | return 1 148 | 149 | groups = ( 150 | self.poetry.package.dependency_group_names(include_optional=True) 151 | if self.option("all-groups") 152 | else self.activated_groups 153 | ) 154 | 155 | exporter = Exporter(self.poetry, self.io) 156 | exporter.only_groups(list(groups)) 157 | exporter.with_extras(list(extras)) 158 | exporter.with_hashes(not self.option("without-hashes")) 159 | exporter.with_credentials(self.option("with-credentials")) 160 | exporter.with_urls(not self.option("without-urls")) 161 | exporter.export(fmt, Path.cwd(), output or self.io) 162 | 163 | return 0 164 | -------------------------------------------------------------------------------- /src/poetry_plugin_export/exporter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import urllib.parse 4 | 5 | from functools import partialmethod 6 | from typing import TYPE_CHECKING 7 | 8 | from cleo.io.io import IO 9 | from poetry.core.packages.dependency_group import MAIN_GROUP 10 | from poetry.core.packages.utils.utils import create_nested_marker 11 | from poetry.core.version.markers import parse_marker 12 | from poetry.repositories.http_repository import HTTPRepository 13 | 14 | from poetry_plugin_export.walker import get_project_dependency_packages 15 | from poetry_plugin_export.walker import get_project_dependency_packages2 16 | 17 | 18 | if TYPE_CHECKING: 19 | from collections.abc import Collection 20 | from collections.abc import Iterable 21 | from pathlib import Path 22 | from typing import ClassVar 23 | 24 | from packaging.utils import NormalizedName 25 | from poetry.poetry import Poetry 26 | 27 | 28 | class Exporter: 29 | """ 30 | Exporter class to export a lock file to alternative formats. 31 | """ 32 | 33 | FORMAT_CONSTRAINTS_TXT = "constraints.txt" 34 | FORMAT_REQUIREMENTS_TXT = "requirements.txt" 35 | ALLOWED_HASH_ALGORITHMS = ("sha256", "sha384", "sha512") 36 | 37 | EXPORT_METHODS: ClassVar[dict[str, str]] = { 38 | FORMAT_CONSTRAINTS_TXT: "_export_constraints_txt", 39 | FORMAT_REQUIREMENTS_TXT: "_export_requirements_txt", 40 | } 41 | 42 | def __init__(self, poetry: Poetry, io: IO) -> None: 43 | self._poetry = poetry 44 | self._io = io 45 | self._with_hashes = True 46 | self._with_credentials = False 47 | self._with_urls = True 48 | self._extras: Collection[NormalizedName] = () 49 | self._groups: Iterable[str] = [MAIN_GROUP] 50 | 51 | @classmethod 52 | def is_format_supported(cls, fmt: str) -> bool: 53 | return fmt in cls.EXPORT_METHODS 54 | 55 | def with_extras(self, extras: Collection[NormalizedName]) -> Exporter: 56 | self._extras = extras 57 | 58 | return self 59 | 60 | def only_groups(self, groups: Iterable[str]) -> Exporter: 61 | self._groups = groups 62 | 63 | return self 64 | 65 | def with_urls(self, with_urls: bool = True) -> Exporter: 66 | self._with_urls = with_urls 67 | 68 | return self 69 | 70 | def with_hashes(self, with_hashes: bool = True) -> Exporter: 71 | self._with_hashes = with_hashes 72 | 73 | return self 74 | 75 | def with_credentials(self, with_credentials: bool = True) -> Exporter: 76 | self._with_credentials = with_credentials 77 | 78 | return self 79 | 80 | def export(self, fmt: str, cwd: Path, output: IO | str) -> None: 81 | if not self.is_format_supported(fmt): 82 | raise ValueError(f"Invalid export format: {fmt}") 83 | 84 | getattr(self, self.EXPORT_METHODS[fmt])(cwd, output) 85 | 86 | def _export_generic_txt( 87 | self, cwd: Path, output: IO | str, with_extras: bool, allow_editable: bool 88 | ) -> None: 89 | from poetry.core.packages.utils.utils import path_to_url 90 | 91 | indexes = set() 92 | content = "" 93 | dependency_lines = set() 94 | 95 | python_marker = parse_marker( 96 | create_nested_marker( 97 | "python_version", self._poetry.package.python_constraint 98 | ) 99 | ) 100 | if self._poetry.locker.is_locked_groups_and_markers(): 101 | dependency_package_iterator = get_project_dependency_packages2( 102 | self._poetry.locker, 103 | project_python_marker=python_marker, 104 | groups=set(self._groups), 105 | extras=self._extras, 106 | ) 107 | else: 108 | root = self._poetry.package.with_dependency_groups( 109 | list(self._groups), only=True 110 | ) 111 | dependency_package_iterator = get_project_dependency_packages( 112 | self._poetry.locker, 113 | project_requires=root.all_requires, 114 | root_package_name=root.name, 115 | project_python_marker=python_marker, 116 | extras=self._extras, 117 | ) 118 | 119 | for dependency_package in dependency_package_iterator: 120 | line = "" 121 | 122 | if not with_extras: 123 | dependency_package = dependency_package.without_features() 124 | 125 | dependency = dependency_package.dependency 126 | package = dependency_package.package 127 | 128 | if package.develop and not allow_editable: 129 | self._io.write_error_line( 130 | f"Warning: {package.pretty_name} is locked in develop" 131 | " (editable) mode, which is incompatible with the" 132 | " constraints.txt format." 133 | ) 134 | continue 135 | 136 | requirement = dependency.to_pep_508(with_extras=False, resolved=True) 137 | is_direct_local_reference = ( 138 | dependency.is_file() or dependency.is_directory() 139 | ) 140 | is_direct_remote_reference = dependency.is_vcs() or dependency.is_url() 141 | 142 | if is_direct_remote_reference: 143 | line = requirement 144 | elif is_direct_local_reference: 145 | assert dependency.source_url is not None 146 | dependency_uri = path_to_url(dependency.source_url) 147 | if package.develop: 148 | line = f"-e {dependency_uri}" 149 | else: 150 | line = f"{package.complete_name} @ {dependency_uri}" 151 | else: 152 | line = f"{package.complete_name}=={package.version}" 153 | 154 | if not is_direct_remote_reference and ";" in requirement: 155 | markers = requirement.split(";", 1)[1].strip() 156 | if markers: 157 | line += f" ; {markers}" 158 | 159 | if ( 160 | not is_direct_remote_reference 161 | and not is_direct_local_reference 162 | and package.source_url 163 | ): 164 | indexes.add(package.source_url.rstrip("/")) 165 | 166 | if package.files and self._with_hashes: 167 | hashes = [] 168 | for f in package.files: 169 | h = f["hash"] 170 | algorithm = "sha256" 171 | if ":" in h: 172 | algorithm, h = h.split(":") 173 | 174 | if algorithm not in self.ALLOWED_HASH_ALGORITHMS: 175 | continue 176 | 177 | hashes.append(f"{algorithm}:{h}") 178 | 179 | hashes.sort() 180 | 181 | for h in hashes: 182 | line += f" \\\n --hash={h}" 183 | 184 | dependency_lines.add(line) 185 | 186 | content += "\n".join(sorted(dependency_lines)) 187 | content += "\n" 188 | 189 | if indexes and self._with_urls: 190 | # If we have extra indexes, we add them to the beginning of the output 191 | indexes_header = "" 192 | has_pypi_repository = any( 193 | r.name.lower() == "pypi" for r in self._poetry.pool.all_repositories 194 | ) 195 | # Iterate over repositories so that we get the repository with the highest 196 | # priority first so that --index-url comes before --extra-index-url 197 | for repository in self._poetry.pool.all_repositories: 198 | if ( 199 | not isinstance(repository, HTTPRepository) 200 | or repository.url not in indexes 201 | ): 202 | continue 203 | 204 | url = ( 205 | repository.authenticated_url 206 | if self._with_credentials 207 | else repository.url 208 | ) 209 | parsed_url = urllib.parse.urlsplit(url) 210 | if parsed_url.scheme == "http": 211 | indexes_header += f"--trusted-host {parsed_url.netloc}\n" 212 | if ( 213 | not has_pypi_repository 214 | and repository is self._poetry.pool.repositories[0] 215 | ): 216 | indexes_header += f"--index-url {url}\n" 217 | else: 218 | indexes_header += f"--extra-index-url {url}\n" 219 | 220 | content = indexes_header + "\n" + content 221 | 222 | if isinstance(output, IO): 223 | output.write(content) 224 | else: 225 | with (cwd / output).open("w", encoding="utf-8") as txt: 226 | txt.write(content) 227 | 228 | _export_constraints_txt = partialmethod( 229 | _export_generic_txt, with_extras=False, allow_editable=False 230 | ) 231 | 232 | _export_requirements_txt = partialmethod( 233 | _export_generic_txt, with_extras=True, allow_editable=True 234 | ) 235 | -------------------------------------------------------------------------------- /src/poetry_plugin_export/plugins.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from poetry.plugins.application_plugin import ApplicationPlugin 6 | 7 | from poetry_plugin_export.command import ExportCommand 8 | 9 | 10 | if TYPE_CHECKING: 11 | from poetry.console.application import Application 12 | from poetry.console.commands.command import Command 13 | 14 | 15 | class ExportApplicationPlugin(ApplicationPlugin): 16 | @property 17 | def commands(self) -> list[type[Command]]: 18 | return [ExportCommand] 19 | 20 | def activate(self, application: Application) -> None: 21 | # Removing the existing export command to avoid an error 22 | # until Poetry removes the export command 23 | # and uses this plugin instead. 24 | 25 | # If you're checking this code out to get inspiration 26 | # for your own plugins: DON'T DO THIS! 27 | if application.command_loader.has("export"): 28 | del application.command_loader._factories["export"] 29 | 30 | super().activate(application=application) 31 | -------------------------------------------------------------------------------- /src/poetry_plugin_export/walker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from packaging.utils import canonicalize_name 6 | from poetry.core.constraints.version.util import constraint_regions 7 | from poetry.core.version.markers import AnyMarker 8 | from poetry.core.version.markers import SingleMarker 9 | from poetry.packages import DependencyPackage 10 | from poetry.utils.extras import get_extra_package_names 11 | 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Collection 15 | from collections.abc import Iterable 16 | from collections.abc import Iterator 17 | 18 | from packaging.utils import NormalizedName 19 | from poetry.core.packages.dependency import Dependency 20 | from poetry.core.packages.package import Package 21 | from poetry.core.version.markers import BaseMarker 22 | from poetry.packages import Locker 23 | 24 | 25 | def get_python_version_region_markers(packages: list[Package]) -> list[BaseMarker]: 26 | markers = [] 27 | 28 | regions = constraint_regions([package.python_constraint for package in packages]) 29 | for region in regions: 30 | marker: BaseMarker = AnyMarker() 31 | if region.min is not None: 32 | min_operator = ">=" if region.include_min else ">" 33 | marker_name = ( 34 | "python_full_version" if region.min.precision > 2 else "python_version" 35 | ) 36 | lo = SingleMarker(marker_name, f"{min_operator} {region.min}") 37 | marker = marker.intersect(lo) 38 | 39 | if region.max is not None: 40 | max_operator = "<=" if region.include_max else "<" 41 | marker_name = ( 42 | "python_full_version" if region.max.precision > 2 else "python_version" 43 | ) 44 | hi = SingleMarker(marker_name, f"{max_operator} {region.max}") 45 | marker = marker.intersect(hi) 46 | 47 | markers.append(marker) 48 | 49 | return markers 50 | 51 | 52 | def get_project_dependency_packages( 53 | locker: Locker, 54 | project_requires: list[Dependency], 55 | root_package_name: NormalizedName, 56 | project_python_marker: BaseMarker | None = None, 57 | extras: Collection[NormalizedName] = (), 58 | ) -> Iterator[DependencyPackage]: 59 | # Apply the project python marker to all requirements. 60 | if project_python_marker is not None: 61 | marked_requires: list[Dependency] = [] 62 | for require in project_requires: 63 | require = require.clone() 64 | require.marker = require.marker.intersect(project_python_marker) 65 | marked_requires.append(require) 66 | project_requires = marked_requires 67 | 68 | repository = locker.locked_repository() 69 | 70 | # Build a set of all packages required by our selected extras 71 | locked_extras = { 72 | canonicalize_name(extra): [ 73 | canonicalize_name(dependency) for dependency in dependencies 74 | ] 75 | for extra, dependencies in locker.lock_data.get("extras", {}).items() 76 | } 77 | extra_package_names = get_extra_package_names( 78 | repository.packages, 79 | locked_extras, 80 | extras, 81 | ) 82 | 83 | # If a package is optional and we haven't opted in to it, do not select 84 | selected = [] 85 | for dependency in project_requires: 86 | try: 87 | package = repository.find_packages(dependency=dependency)[0] 88 | except IndexError: 89 | continue 90 | 91 | if package.optional and package.name not in extra_package_names: 92 | # a package is locked as optional, but is not activated via extras 93 | continue 94 | 95 | selected.append(dependency) 96 | 97 | for package, dependency in get_project_dependencies( 98 | project_requires=selected, 99 | locked_packages=repository.packages, 100 | root_package_name=root_package_name, 101 | ): 102 | yield DependencyPackage(dependency=dependency, package=package) 103 | 104 | 105 | def get_project_dependencies( 106 | project_requires: list[Dependency], 107 | locked_packages: list[Package], 108 | root_package_name: NormalizedName, 109 | ) -> Iterable[tuple[Package, Dependency]]: 110 | # group packages entries by name, this is required because requirement might use 111 | # different constraints. 112 | packages_by_name: dict[str, list[Package]] = {} 113 | for pkg in locked_packages: 114 | if pkg.name not in packages_by_name: 115 | packages_by_name[pkg.name] = [] 116 | packages_by_name[pkg.name].append(pkg) 117 | 118 | # Put higher versions first so that we prefer them. 119 | for packages in packages_by_name.values(): 120 | packages.sort( 121 | key=lambda package: package.version, 122 | reverse=True, 123 | ) 124 | 125 | nested_dependencies = walk_dependencies( 126 | dependencies=project_requires, 127 | packages_by_name=packages_by_name, 128 | root_package_name=root_package_name, 129 | ) 130 | 131 | return nested_dependencies.items() 132 | 133 | 134 | def walk_dependencies( 135 | dependencies: list[Dependency], 136 | packages_by_name: dict[str, list[Package]], 137 | root_package_name: NormalizedName, 138 | ) -> dict[Package, Dependency]: 139 | nested_dependencies: dict[Package, Dependency] = {} 140 | 141 | visited: set[tuple[Dependency, BaseMarker]] = set() 142 | while dependencies: 143 | requirement = dependencies.pop(0) 144 | if (requirement, requirement.marker) in visited: 145 | continue 146 | if requirement.name == root_package_name: 147 | continue 148 | visited.add((requirement, requirement.marker)) 149 | 150 | locked_package = get_locked_package( 151 | requirement, packages_by_name, nested_dependencies 152 | ) 153 | 154 | if not locked_package: 155 | raise RuntimeError(f"Dependency walk failed at {requirement}") 156 | 157 | if requirement.extras: 158 | locked_package = locked_package.with_features(requirement.extras) 159 | 160 | # create dependency from locked package to retain dependency metadata 161 | # if this is not done, we can end-up with incorrect nested dependencies 162 | constraint = requirement.constraint 163 | marker = requirement.marker 164 | requirement = locked_package.to_dependency() 165 | requirement.marker = requirement.marker.intersect(marker) 166 | 167 | requirement.constraint = constraint 168 | 169 | for require in locked_package.requires: 170 | if require.is_optional() and not any( 171 | require in locked_package.extras.get(feature, ()) 172 | for feature in locked_package.features 173 | ): 174 | continue 175 | 176 | base_marker = require.marker.intersect(requirement.marker).without_extras() 177 | 178 | if not base_marker.is_empty(): 179 | # So as to give ourselves enough flexibility in choosing a solution, 180 | # we need to split the world up into the python version ranges that 181 | # this package might care about. 182 | # 183 | # We create a marker for all of the possible regions, and add a 184 | # requirement for each separately. 185 | candidates = packages_by_name.get(require.name, []) 186 | region_markers = get_python_version_region_markers(candidates) 187 | for region_marker in region_markers: 188 | marker = region_marker.intersect(base_marker) 189 | if not marker.is_empty(): 190 | require2 = require.clone() 191 | require2.marker = marker 192 | dependencies.append(require2) 193 | 194 | key = locked_package 195 | if key not in nested_dependencies: 196 | nested_dependencies[key] = requirement 197 | else: 198 | nested_dependencies[key].marker = nested_dependencies[key].marker.union( 199 | requirement.marker 200 | ) 201 | 202 | return nested_dependencies 203 | 204 | 205 | def get_locked_package( 206 | dependency: Dependency, 207 | packages_by_name: dict[str, list[Package]], 208 | decided: dict[Package, Dependency] | None = None, 209 | ) -> Package | None: 210 | """ 211 | Internal helper to identify corresponding locked package using dependency 212 | version constraints. 213 | """ 214 | decided = decided or {} 215 | 216 | candidates = packages_by_name.get(dependency.name, []) 217 | 218 | # If we've previously chosen a version of this package that is compatible with 219 | # the current requirement, we are forced to stick with it. (Else we end up with 220 | # different versions of the same package at the same time.) 221 | overlapping_candidates = set() 222 | for package in candidates: 223 | old_decision = decided.get(package) 224 | if ( 225 | old_decision is not None 226 | and not old_decision.marker.intersect(dependency.marker).is_empty() 227 | ): 228 | overlapping_candidates.add(package) 229 | 230 | # If we have more than one overlapping candidate, we've run into trouble. 231 | if len(overlapping_candidates) > 1: 232 | return None 233 | 234 | # Get the packages that are consistent with this dependency. 235 | compatible_candidates = [ 236 | package 237 | for package in candidates 238 | if package.python_constraint.allows_all(dependency.python_constraint) 239 | and dependency.constraint.allows(package.version) 240 | and (dependency.source_type is None or dependency.is_same_source_as(package)) 241 | ] 242 | 243 | # If we have an overlapping candidate, we must use it. 244 | if overlapping_candidates: 245 | filtered_compatible_candidates = [ 246 | package 247 | for package in compatible_candidates 248 | if package in overlapping_candidates 249 | ] 250 | 251 | if not filtered_compatible_candidates: 252 | # TODO: Support this case: 253 | # https://github.com/python-poetry/poetry-plugin-export/issues/183 254 | raise DependencyWalkerError( 255 | f"The `{dependency.name}` package has the following compatible" 256 | f" candidates `{compatible_candidates}`; but, the exporter dependency" 257 | f" walker previously elected `{overlapping_candidates.pop()}` which is" 258 | f" not compatible with the dependency `{dependency}`. Please contribute" 259 | " to `poetry-plugin-export` to solve this problem." 260 | ) 261 | 262 | compatible_candidates = filtered_compatible_candidates 263 | 264 | return next(iter(compatible_candidates), None) 265 | 266 | 267 | def get_project_dependency_packages2( 268 | locker: Locker, 269 | project_python_marker: BaseMarker | None = None, 270 | groups: Collection[str] = (), 271 | extras: Collection[NormalizedName] = (), 272 | ) -> Iterator[DependencyPackage]: 273 | for package, info in locker.locked_packages().items(): 274 | if not info.groups.intersection(groups): 275 | continue 276 | 277 | marker = info.get_marker(groups) 278 | if not marker.validate({"extra": extras}): 279 | continue 280 | 281 | if project_python_marker: 282 | marker = project_python_marker.intersect(marker) 283 | 284 | package.marker = marker 285 | 286 | yield DependencyPackage(dependency=package.to_dependency(), package=package) 287 | 288 | 289 | class DependencyWalkerError(Exception): 290 | pass 291 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/1a56521af04d0e05c327c8d8740f51dc3f715fa1/tests/__init__.py -------------------------------------------------------------------------------- /tests/command/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/1a56521af04d0e05c327c8d8740f51dc3f715fa1/tests/command/__init__.py -------------------------------------------------------------------------------- /tests/command/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from cleo.io.null_io import NullIO 8 | from cleo.testers.command_tester import CommandTester 9 | from poetry.console.commands.env_command import EnvCommand 10 | from poetry.console.commands.installer_command import InstallerCommand 11 | from poetry.installation import Installer 12 | from poetry.utils.env import MockEnv 13 | 14 | from tests.helpers import PoetryTestApplication 15 | from tests.helpers import TestExecutor 16 | 17 | 18 | if TYPE_CHECKING: 19 | from pathlib import Path 20 | 21 | from poetry.installation.executor import Executor 22 | from poetry.poetry import Poetry 23 | from poetry.utils.env import Env 24 | 25 | from tests.types import CommandTesterFactory 26 | 27 | 28 | @pytest.fixture 29 | def app(poetry: Poetry) -> PoetryTestApplication: 30 | app_ = PoetryTestApplication(poetry) 31 | 32 | return app_ 33 | 34 | 35 | @pytest.fixture 36 | def env(tmp_path: Path) -> MockEnv: 37 | path = tmp_path / ".venv" 38 | path.mkdir(parents=True) 39 | return MockEnv(path=path, is_venv=True) 40 | 41 | 42 | @pytest.fixture 43 | def command_tester_factory( 44 | app: PoetryTestApplication, env: MockEnv 45 | ) -> CommandTesterFactory: 46 | def _tester( 47 | command: str, 48 | poetry: Poetry | None = None, 49 | installer: Installer | None = None, 50 | executor: Executor | None = None, 51 | environment: Env | None = None, 52 | ) -> CommandTester: 53 | app._load_plugins(NullIO()) 54 | 55 | cmd = app.find(command) 56 | tester = CommandTester(cmd) 57 | 58 | # Setting the formatter from the application 59 | # TODO: Find a better way to do this in Cleo 60 | app_io = app.create_io() 61 | formatter = app_io.output.formatter 62 | tester.io.output.set_formatter(formatter) 63 | tester.io.error_output.set_formatter(formatter) 64 | 65 | if poetry: 66 | app._poetry = poetry 67 | 68 | poetry = app.poetry 69 | 70 | if isinstance(cmd, EnvCommand): 71 | cmd.set_env(environment or env) 72 | 73 | if isinstance(cmd, InstallerCommand): 74 | installer = installer or Installer( 75 | tester.io, 76 | env, 77 | poetry.package, 78 | poetry.locker, 79 | poetry.pool, 80 | poetry.config, 81 | executor=executor 82 | or TestExecutor(env, poetry.pool, poetry.config, tester.io), 83 | ) 84 | cmd.set_installer(installer) 85 | 86 | return tester 87 | 88 | return _tester 89 | 90 | 91 | @pytest.fixture 92 | def do_lock(command_tester_factory: CommandTesterFactory, poetry: Poetry) -> None: 93 | command_tester_factory("lock").execute() 94 | assert poetry.locker.lock.exists() 95 | -------------------------------------------------------------------------------- /tests/command/test_command_export.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import shutil 4 | 5 | from typing import TYPE_CHECKING 6 | from unittest.mock import Mock 7 | 8 | import pytest 9 | 10 | from poetry.core.packages.dependency_group import MAIN_GROUP 11 | from poetry.core.packages.package import Package 12 | 13 | from poetry_plugin_export.exporter import Exporter 14 | from tests.markers import MARKER_PY 15 | 16 | 17 | if TYPE_CHECKING: 18 | from pathlib import Path 19 | 20 | from _pytest.monkeypatch import MonkeyPatch 21 | from cleo.testers.command_tester import CommandTester 22 | from poetry.poetry import Poetry 23 | from poetry.repositories import Repository 24 | from pytest_mock import MockerFixture 25 | 26 | from tests.types import CommandTesterFactory 27 | from tests.types import ProjectFactory 28 | 29 | 30 | PYPROJECT_CONTENT = """\ 31 | [tool.poetry] 32 | name = "simple-project" 33 | version = "1.2.3" 34 | description = "Some description." 35 | authors = [ 36 | "Sébastien Eustace " 37 | ] 38 | license = "MIT" 39 | 40 | readme = "README.rst" 41 | 42 | homepage = "https://python-poetry.org" 43 | repository = "https://github.com/python-poetry/poetry" 44 | documentation = "https://python-poetry.org/docs" 45 | 46 | keywords = ["packaging", "dependency", "poetry"] 47 | 48 | classifiers = [ 49 | "Topic :: Software Development :: Build Tools", 50 | "Topic :: Software Development :: Libraries :: Python Modules" 51 | ] 52 | 53 | # Requirements 54 | [tool.poetry.dependencies] 55 | python = "~2.7 || ^3.6" 56 | foo = "^1.0" 57 | bar = { version = "^1.1", optional = true } 58 | qux = { version = "^1.2", optional = true } 59 | 60 | [tool.poetry.group.dev.dependencies] 61 | baz = "^2.0" 62 | 63 | [tool.poetry.group.opt] 64 | optional = true 65 | 66 | [tool.poetry.group.opt.dependencies] 67 | opt = "^2.2" 68 | 69 | 70 | [tool.poetry.extras] 71 | feature_bar = ["bar"] 72 | feature_qux = ["qux"] 73 | """ 74 | 75 | 76 | @pytest.fixture(autouse=True) 77 | def setup(repo: Repository) -> None: 78 | repo.add_package(Package("foo", "1.0.0")) 79 | repo.add_package(Package("bar", "1.1.0")) 80 | repo.add_package(Package("baz", "2.0.0")) 81 | repo.add_package(Package("opt", "2.2.0")) 82 | repo.add_package(Package("qux", "1.2.0")) 83 | 84 | 85 | @pytest.fixture 86 | def poetry(project_factory: ProjectFactory) -> Poetry: 87 | return project_factory(name="export", pyproject_content=PYPROJECT_CONTENT) 88 | 89 | 90 | @pytest.fixture 91 | def tester( 92 | command_tester_factory: CommandTesterFactory, poetry: Poetry 93 | ) -> CommandTester: 94 | return command_tester_factory("export", poetry=poetry) 95 | 96 | 97 | def _export_requirements(tester: CommandTester, poetry: Poetry, tmp_path: Path) -> None: 98 | from tests.helpers import as_cwd 99 | 100 | with as_cwd(tmp_path): 101 | tester.execute("--format requirements.txt --output requirements.txt") 102 | 103 | requirements = tmp_path / "requirements.txt" 104 | assert requirements.exists() 105 | 106 | with requirements.open(encoding="utf-8") as f: 107 | content = f.read() 108 | 109 | assert poetry.locker.lock.exists() 110 | 111 | expected = f"""\ 112 | foo==1.0.0 ; {MARKER_PY} 113 | """ 114 | 115 | assert content == expected 116 | 117 | 118 | def test_export_exports_requirements_txt_file_locks_if_no_lock_file( 119 | tester: CommandTester, poetry: Poetry, tmp_path: Path 120 | ) -> None: 121 | assert not poetry.locker.lock.exists() 122 | _export_requirements(tester, poetry, tmp_path) 123 | assert "The lock file does not exist. Locking." in tester.io.fetch_error() 124 | 125 | 126 | def test_export_exports_requirements_txt_uses_lock_file( 127 | tester: CommandTester, poetry: Poetry, tmp_path: Path, do_lock: None 128 | ) -> None: 129 | _export_requirements(tester, poetry, tmp_path) 130 | assert "The lock file does not exist. Locking." not in tester.io.fetch_error() 131 | 132 | 133 | def test_export_fails_on_invalid_format(tester: CommandTester, do_lock: None) -> None: 134 | with pytest.raises(ValueError): 135 | tester.execute("--format invalid") 136 | 137 | 138 | def test_export_fails_if_lockfile_is_not_fresh( 139 | tester: CommandTester, 140 | poetry: Poetry, 141 | tmp_path: Path, 142 | do_lock: None, 143 | mocker: MockerFixture, 144 | ) -> None: 145 | mocker.patch.object(poetry.locker, "is_fresh", return_value=False) 146 | assert tester.execute() == 1 147 | assert "pyproject.toml changed significantly" in tester.io.fetch_error() 148 | 149 | 150 | def test_export_prints_to_stdout_by_default( 151 | tester: CommandTester, do_lock: None 152 | ) -> None: 153 | tester.execute("--format requirements.txt") 154 | expected = f"""\ 155 | foo==1.0.0 ; {MARKER_PY} 156 | """ 157 | assert tester.io.fetch_output() == expected 158 | 159 | 160 | def test_export_uses_requirements_txt_format_by_default( 161 | tester: CommandTester, do_lock: None 162 | ) -> None: 163 | tester.execute() 164 | expected = f"""\ 165 | foo==1.0.0 ; {MARKER_PY} 166 | """ 167 | assert tester.io.fetch_output() == expected 168 | 169 | 170 | @pytest.mark.parametrize( 171 | "options, expected", 172 | [ 173 | ("", f"foo==1.0.0 ; {MARKER_PY}\n"), 174 | ("--with dev", f"baz==2.0.0 ; {MARKER_PY}\nfoo==1.0.0 ; {MARKER_PY}\n"), 175 | ("--with opt", f"foo==1.0.0 ; {MARKER_PY}\nopt==2.2.0 ; {MARKER_PY}\n"), 176 | ( 177 | "--with dev,opt", 178 | ( 179 | f"baz==2.0.0 ; {MARKER_PY}\nfoo==1.0.0 ; {MARKER_PY}\nopt==2.2.0 ;" 180 | f" {MARKER_PY}\n" 181 | ), 182 | ), 183 | (f"--without {MAIN_GROUP}", "\n"), 184 | ("--without dev", f"foo==1.0.0 ; {MARKER_PY}\n"), 185 | ("--without opt", f"foo==1.0.0 ; {MARKER_PY}\n"), 186 | (f"--without {MAIN_GROUP},dev,opt", "\n"), 187 | (f"--only {MAIN_GROUP}", f"foo==1.0.0 ; {MARKER_PY}\n"), 188 | ("--only dev", f"baz==2.0.0 ; {MARKER_PY}\n"), 189 | ( 190 | f"--only {MAIN_GROUP},dev", 191 | f"baz==2.0.0 ; {MARKER_PY}\nfoo==1.0.0 ; {MARKER_PY}\n", 192 | ), 193 | ], 194 | ) 195 | def test_export_groups( 196 | tester: CommandTester, do_lock: None, options: str, expected: str 197 | ) -> None: 198 | tester.execute(options) 199 | assert tester.io.fetch_output() == expected 200 | 201 | 202 | @pytest.mark.parametrize( 203 | "extras, expected", 204 | [ 205 | ( 206 | "feature_bar", 207 | f"""\ 208 | bar==1.1.0 ; {MARKER_PY} 209 | foo==1.0.0 ; {MARKER_PY} 210 | """, 211 | ), 212 | ( 213 | "feature_bar feature_qux", 214 | f"""\ 215 | bar==1.1.0 ; {MARKER_PY} 216 | foo==1.0.0 ; {MARKER_PY} 217 | qux==1.2.0 ; {MARKER_PY} 218 | """, 219 | ), 220 | ], 221 | ) 222 | def test_export_includes_extras_by_flag( 223 | tester: CommandTester, do_lock: None, extras: str, expected: str 224 | ) -> None: 225 | tester.execute(f"--format requirements.txt --extras '{extras}'") 226 | assert tester.io.fetch_output() == expected 227 | 228 | 229 | def test_export_reports_invalid_extras(tester: CommandTester, do_lock: None) -> None: 230 | with pytest.raises(ValueError) as error: 231 | tester.execute("--format requirements.txt --extras 'SUS AMONGUS'") 232 | expected = "Extra [amongus, sus] is not specified." 233 | assert str(error.value) == expected 234 | 235 | 236 | def test_export_with_all_extras(tester: CommandTester, do_lock: None) -> None: 237 | tester.execute("--format requirements.txt --all-extras") 238 | output = tester.io.fetch_output() 239 | assert f"bar==1.1.0 ; {MARKER_PY}" in output 240 | assert f"qux==1.2.0 ; {MARKER_PY}" in output 241 | 242 | 243 | def test_extras_conflicts_all_extras(tester: CommandTester, do_lock: None) -> None: 244 | tester.execute("--extras bar --all-extras") 245 | 246 | assert tester.status_code == 1 247 | assert ( 248 | "You cannot specify explicit `--extras` while exporting using `--all-extras`.\n" 249 | in tester.io.fetch_error() 250 | ) 251 | 252 | 253 | def test_export_with_all_groups(tester: CommandTester, do_lock: None) -> None: 254 | tester.execute("--format requirements.txt --all-groups") 255 | output = tester.io.fetch_output() 256 | assert f"baz==2.0.0 ; {MARKER_PY}" in output 257 | assert f"opt==2.2.0 ; {MARKER_PY}" in output 258 | 259 | 260 | @pytest.mark.parametrize("flag", ["--with", "--without", "--only"]) 261 | def test_with_conflicts_all_groups( 262 | tester: CommandTester, do_lock: None, flag: str 263 | ) -> None: 264 | tester.execute(f"{flag}=bar --all-groups") 265 | 266 | assert tester.status_code == 1 267 | assert ( 268 | "You cannot specify explicit `--with`, `--without`," 269 | " or `--only` while exporting using `--all-groups`.\n" 270 | in tester.io.fetch_error() 271 | ) 272 | 273 | 274 | def test_export_with_urls( 275 | monkeypatch: MonkeyPatch, tester: CommandTester, poetry: Poetry 276 | ) -> None: 277 | """ 278 | We are just validating that the option gets passed. The option itself is tested in 279 | the Exporter test. 280 | """ 281 | mock_export = Mock() 282 | monkeypatch.setattr(Exporter, "with_urls", mock_export) 283 | tester.execute("--without-urls") 284 | mock_export.assert_called_once_with(False) 285 | 286 | 287 | def test_export_exports_constraints_txt_with_warnings( 288 | tmp_path: Path, 289 | fixture_root: Path, 290 | project_factory: ProjectFactory, 291 | command_tester_factory: CommandTesterFactory, 292 | ) -> None: 293 | # On Windows we have to make sure that the path dependency and the pyproject.toml 294 | # are on the same drive, otherwise locking fails. 295 | # (in our CI fixture_root is on D:\ but temp_path is on C:\) 296 | editable_dep_path = tmp_path / "project_with_nested_local" 297 | shutil.copytree(fixture_root / "project_with_nested_local", editable_dep_path) 298 | 299 | pyproject_content = f"""\ 300 | [tool.poetry] 301 | name = "simple-project" 302 | version = "1.2.3" 303 | description = "Some description." 304 | authors = [ 305 | "Sébastien Eustace " 306 | ] 307 | 308 | [tool.poetry.dependencies] 309 | python = "^3.6" 310 | baz = ">1.0" 311 | project-with-nested-local = {{ path = "{editable_dep_path.as_posix()}", \ 312 | develop = true }} 313 | """ 314 | poetry = project_factory(name="export", pyproject_content=pyproject_content) 315 | tester = command_tester_factory("export", poetry=poetry) 316 | tester.execute("--format constraints.txt") 317 | 318 | develop_warning = ( 319 | "Warning: project-with-nested-local is locked in develop (editable) mode, which" 320 | " is incompatible with the constraints.txt format.\n" 321 | ) 322 | expected = 'baz==2.0.0 ; python_version >= "3.6" and python_version < "4.0"\n' 323 | 324 | assert develop_warning in tester.io.fetch_error() 325 | assert tester.io.fetch_output() == expected 326 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | from typing import Any 8 | 9 | import pytest 10 | 11 | from poetry.config.config import Config as BaseConfig 12 | from poetry.config.dict_config_source import DictConfigSource 13 | from poetry.core.packages.package import Package 14 | from poetry.factory import Factory 15 | from poetry.layouts import layout 16 | from poetry.repositories import Repository 17 | from poetry.repositories.repository_pool import RepositoryPool 18 | from poetry.utils.env import SystemEnv 19 | 20 | from tests.helpers import TestLocker 21 | 22 | 23 | if TYPE_CHECKING: 24 | from poetry.poetry import Poetry 25 | from pytest_mock import MockerFixture 26 | 27 | from tests.types import ProjectFactory 28 | 29 | 30 | class Config(BaseConfig): 31 | def get(self, setting_name: str, default: Any = None) -> Any: 32 | self.merge(self._config_source.config) # type: ignore[attr-defined] 33 | self.merge(self._auth_config_source.config) # type: ignore[attr-defined] 34 | 35 | return super().get(setting_name, default=default) 36 | 37 | def raw(self) -> dict[str, Any]: 38 | self.merge(self._config_source.config) # type: ignore[attr-defined] 39 | self.merge(self._auth_config_source.config) # type: ignore[attr-defined] 40 | 41 | return super().raw() 42 | 43 | def all(self) -> dict[str, Any]: 44 | self.merge(self._config_source.config) # type: ignore[attr-defined] 45 | self.merge(self._auth_config_source.config) # type: ignore[attr-defined] 46 | 47 | return super().all() 48 | 49 | 50 | @pytest.fixture 51 | def config_cache_dir(tmp_path: Path) -> Path: 52 | path = tmp_path / ".cache" / "pypoetry" 53 | path.mkdir(parents=True) 54 | 55 | return path 56 | 57 | 58 | @pytest.fixture 59 | def config_source(config_cache_dir: Path) -> DictConfigSource: 60 | source = DictConfigSource() 61 | source.add_property("cache-dir", str(config_cache_dir)) 62 | 63 | return source 64 | 65 | 66 | @pytest.fixture 67 | def auth_config_source() -> DictConfigSource: 68 | source = DictConfigSource() 69 | 70 | return source 71 | 72 | 73 | @pytest.fixture 74 | def config( 75 | config_source: DictConfigSource, 76 | auth_config_source: DictConfigSource, 77 | mocker: MockerFixture, 78 | ) -> Config: 79 | c = Config() 80 | c.merge(config_source.config) 81 | c.config["keyring"]["enabled"] = False 82 | c.set_config_source(config_source) 83 | c.set_auth_config_source(auth_config_source) 84 | 85 | mocker.patch("poetry.config.config.Config.create", return_value=c) 86 | mocker.patch("poetry.config.config.Config.set_config_source") 87 | 88 | return c 89 | 90 | 91 | @pytest.fixture 92 | def fixture_root() -> Path: 93 | return Path(__file__).parent / "fixtures" 94 | 95 | 96 | @pytest.fixture 97 | def fixture_root_uri(fixture_root: Path) -> str: 98 | return fixture_root.as_uri() 99 | 100 | 101 | @pytest.fixture() 102 | def repo() -> Repository: 103 | return Repository("repo") 104 | 105 | 106 | @pytest.fixture 107 | def installed() -> Repository: 108 | return Repository("installed") 109 | 110 | 111 | @pytest.fixture(scope="session") 112 | def current_env() -> SystemEnv: 113 | return SystemEnv(Path(sys.executable)) 114 | 115 | 116 | @pytest.fixture(scope="session") 117 | def current_python(current_env: SystemEnv) -> tuple[Any, ...]: 118 | return current_env.version_info[:3] 119 | 120 | 121 | @pytest.fixture(scope="session") 122 | def default_python(current_python: tuple[int, int, int]) -> str: 123 | return "^" + ".".join(str(v) for v in current_python[:2]) 124 | 125 | 126 | @pytest.fixture 127 | def project_factory( 128 | tmp_path: Path, 129 | config: Config, 130 | repo: Repository, 131 | installed: Repository, 132 | default_python: str, 133 | ) -> ProjectFactory: 134 | def _factory( 135 | name: str, 136 | dependencies: dict[str, str] | None = None, 137 | dev_dependencies: dict[str, str] | None = None, 138 | pyproject_content: str | None = None, 139 | poetry_lock_content: str | None = None, 140 | install_deps: bool = True, 141 | ) -> Poetry: 142 | project_dir = tmp_path / f"poetry-fixture-{name}" 143 | dependencies = dependencies or {} 144 | dev_dependencies = dev_dependencies or {} 145 | 146 | if pyproject_content: 147 | project_dir.mkdir(parents=True, exist_ok=True) 148 | with project_dir.joinpath("pyproject.toml").open( 149 | "w", encoding="utf-8" 150 | ) as f: 151 | f.write(pyproject_content) 152 | else: 153 | layout("src")( 154 | name, 155 | "0.1.0", 156 | author="PyTest Tester ", 157 | readme_format="md", 158 | python=default_python, 159 | dependencies=dict(dependencies), 160 | dev_dependencies=dict(dev_dependencies), 161 | ).create(project_dir, with_tests=False) 162 | 163 | if poetry_lock_content: 164 | lock_file = project_dir / "poetry.lock" 165 | lock_file.write_text(data=poetry_lock_content, encoding="utf-8") 166 | 167 | poetry = Factory().create_poetry(project_dir) 168 | 169 | locker = TestLocker(poetry.locker.lock, poetry.locker._pyproject_data) 170 | locker.write() 171 | 172 | poetry.set_locker(locker) 173 | poetry.set_config(config) 174 | 175 | pool = RepositoryPool() 176 | pool.add_repository(repo) 177 | 178 | poetry.set_pool(pool) 179 | 180 | if install_deps: 181 | for deps in [dependencies, dev_dependencies]: 182 | for name, version in deps.items(): 183 | pkg = Package(name, version) 184 | repo.add_package(pkg) 185 | installed.add_package(pkg) 186 | 187 | return poetry 188 | 189 | return _factory 190 | -------------------------------------------------------------------------------- /tests/fixtures/distributions/demo-0.1.0-py2.py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/1a56521af04d0e05c327c8d8740f51dc3f715fa1/tests/fixtures/distributions/demo-0.1.0-py2.py3-none-any.whl -------------------------------------------------------------------------------- /tests/fixtures/distributions/demo-0.1.0.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/1a56521af04d0e05c327c8d8740f51dc3f715fa1/tests/fixtures/distributions/demo-0.1.0.tar.gz -------------------------------------------------------------------------------- /tests/fixtures/project_with_nested_local/bar/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "bar" 3 | version = "1.2.3" 4 | description = "Some description." 5 | authors = ["Poetry Maintainer "] 6 | license = "MIT" 7 | 8 | # Requirements 9 | [tool.poetry.dependencies] 10 | python = "~2.7 || ^3.4" 11 | quix = { path = "../quix", develop = true } 12 | -------------------------------------------------------------------------------- /tests/fixtures/project_with_nested_local/foo/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "foo" 3 | version = "1.2.3" 4 | description = "Some description." 5 | authors = ["Poetry Maintainer "] 6 | license = "MIT" 7 | 8 | # Requirements 9 | [tool.poetry.dependencies] 10 | python = "~2.7 || ^3.4" 11 | bar = { path = "../bar", develop = true } 12 | -------------------------------------------------------------------------------- /tests/fixtures/project_with_nested_local/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "project-with-nested-local" 3 | version = "1.2.3" 4 | description = "Some description." 5 | authors = ["Poetry Maintainer "] 6 | license = "MIT" 7 | 8 | # Requirements 9 | [tool.poetry.dependencies] 10 | python = "~2.7 || ^3.4" 11 | foo = { path = "./foo", develop = true } 12 | bar = { path = "./bar", develop = true } 13 | -------------------------------------------------------------------------------- /tests/fixtures/project_with_nested_local/quix/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "quix" 3 | version = "1.2.3" 4 | description = "Some description." 5 | authors = ["Poetry Maintainer "] 6 | license = "MIT" 7 | 8 | # Requirements 9 | [tool.poetry.dependencies] 10 | python = "~2.7 || ^3.4" 11 | -------------------------------------------------------------------------------- /tests/fixtures/project_with_setup/my_package/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/1a56521af04d0e05c327c8d8740f51dc3f715fa1/tests/fixtures/project_with_setup/my_package/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/project_with_setup/project_with_setup.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 1.0 2 | Name: project-with-setup 3 | Version: 0.1.2 4 | Summary: Demo project. 5 | Home-page: https://github.com/demo/demo 6 | Author: Sébastien Eustace 7 | Author-email: sebastien@eustace.io 8 | License: MIT 9 | Description: UNKNOWN 10 | Platform: UNKNOWN 11 | -------------------------------------------------------------------------------- /tests/fixtures/project_with_setup/project_with_setup.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- 1 | setup.py 2 | my_package/__init__.py 3 | my_package.egg-info/PKG-INFO 4 | my_package.egg-info/SOURCES.txt 5 | my_package.egg-info/dependency_links.txt 6 | my_package.egg-info/requires.txt 7 | my_package.egg-info/top_level.txt 8 | -------------------------------------------------------------------------------- /tests/fixtures/project_with_setup/project_with_setup.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/1a56521af04d0e05c327c8d8740f51dc3f715fa1/tests/fixtures/project_with_setup/project_with_setup.egg-info/dependency_links.txt -------------------------------------------------------------------------------- /tests/fixtures/project_with_setup/project_with_setup.egg-info/requires.txt: -------------------------------------------------------------------------------- 1 | pendulum>=1.4.4 2 | cachy[msgpack]>=0.2.0 3 | -------------------------------------------------------------------------------- /tests/fixtures/project_with_setup/project_with_setup.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | my_package 2 | -------------------------------------------------------------------------------- /tests/fixtures/project_with_setup/setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from setuptools import setup 4 | 5 | 6 | kwargs = dict( 7 | name="project-with-setup", 8 | license="MIT", 9 | version="0.1.2", 10 | description="Demo project.", 11 | author="Sébastien Eustace", 12 | author_email="sebastien@eustace.io", 13 | url="https://github.com/demo/demo", 14 | packages=["my_package"], 15 | install_requires=["pendulum>=1.4.4", "cachy[msgpack]>=0.2.0"], 16 | ) 17 | 18 | 19 | setup(**kwargs) 20 | -------------------------------------------------------------------------------- /tests/fixtures/sample_project/README.rst: -------------------------------------------------------------------------------- 1 | My Package 2 | ========== 3 | -------------------------------------------------------------------------------- /tests/fixtures/sample_project/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "sample-project" 3 | version = "1.2.3" 4 | description = "Some description." 5 | authors = [ 6 | "Sébastien Eustace " 7 | ] 8 | license = "MIT" 9 | 10 | readme = "README.rst" 11 | 12 | homepage = "https://python-poetry.org" 13 | repository = "https://github.com/python-poetry/poetry" 14 | documentation = "https://python-poetry.org/docs" 15 | 16 | keywords = ["packaging", "dependency", "poetry"] 17 | 18 | classifiers = [ 19 | "Topic :: Software Development :: Build Tools", 20 | "Topic :: Software Development :: Libraries :: Python Modules" 21 | ] 22 | 23 | # Requirements 24 | [tool.poetry.dependencies] 25 | python = "~2.7 || ^3.6" 26 | cleo = "^0.6" 27 | pendulum = { git = "https://github.com/sdispater/pendulum.git", branch = "2.0" } 28 | requests = { version = "^2.18", optional = true, extras=[ "security" ] } 29 | pathlib2 = { version = "^2.2", python = "~2.7" } 30 | 31 | orator = { version = "^0.9", optional = true } 32 | 33 | # File dependency 34 | demo = { path = "../distributions/demo-0.1.0-py2.py3-none-any.whl" } 35 | 36 | # Dir dependency with setup.py 37 | my-package = { path = "../project_with_setup/" } 38 | 39 | # Dir dependency with pyproject.toml 40 | simple-project = { path = "../simple_project/" } 41 | 42 | # Dependency with markers 43 | functools32 = { version = "^3.2.3", markers = "python_version ~= '2.7' and sys_platform == 'win32' or python_version in '3.4 3.5'" } 44 | 45 | 46 | [tool.poetry.extras] 47 | db = [ "orator" ] 48 | 49 | [tool.poetry.dev-dependencies] 50 | pytest = "~3.4" 51 | 52 | 53 | [tool.poetry.scripts] 54 | my-script = "sample_project:main" 55 | 56 | 57 | [tool.poetry.plugins."blogtool.parsers"] 58 | ".rst" = "some_module::SomeClass" 59 | -------------------------------------------------------------------------------- /tests/fixtures/simple_project/README.rst: -------------------------------------------------------------------------------- 1 | My Package 2 | ========== 3 | -------------------------------------------------------------------------------- /tests/fixtures/simple_project/dist/simple-project-1.2.3.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/1a56521af04d0e05c327c8d8740f51dc3f715fa1/tests/fixtures/simple_project/dist/simple-project-1.2.3.tar.gz -------------------------------------------------------------------------------- /tests/fixtures/simple_project/dist/simple_project-1.2.3-py2.py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/1a56521af04d0e05c327c8d8740f51dc3f715fa1/tests/fixtures/simple_project/dist/simple_project-1.2.3-py2.py3-none-any.whl -------------------------------------------------------------------------------- /tests/fixtures/simple_project/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "simple-project" 3 | version = "1.2.3" 4 | description = "Some description." 5 | authors = [ 6 | "Sébastien Eustace " 7 | ] 8 | license = "MIT" 9 | 10 | readme = "README.rst" 11 | 12 | homepage = "https://python-poetry.org" 13 | repository = "https://github.com/python-poetry/poetry" 14 | documentation = "https://python-poetry.org/docs" 15 | 16 | keywords = ["packaging", "dependency", "poetry"] 17 | 18 | classifiers = [ 19 | "Topic :: Software Development :: Build Tools", 20 | "Topic :: Software Development :: Libraries :: Python Modules" 21 | ] 22 | 23 | # Requirements 24 | [tool.poetry.dependencies] 25 | python = "~2.7 || ^3.4" 26 | 27 | [tool.poetry.scripts] 28 | foo = "foo:bar" 29 | baz = "bar:baz.boom.bim" 30 | fox = "fuz.foo:bar.baz" 31 | 32 | 33 | [build-system] 34 | requires = ["poetry-core>=1.0.2"] 35 | build-backend = "poetry.core.masonry.api" 36 | -------------------------------------------------------------------------------- /tests/fixtures/simple_project/simple_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/1a56521af04d0e05c327c8d8740f51dc3f715fa1/tests/fixtures/simple_project/simple_project/__init__.py -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | from contextlib import contextmanager 6 | from typing import TYPE_CHECKING 7 | from typing import Any 8 | 9 | from poetry.console.application import Application 10 | from poetry.factory import Factory 11 | from poetry.installation.executor import Executor 12 | from poetry.packages import Locker 13 | 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterator 17 | from pathlib import Path 18 | 19 | from poetry.core.packages.package import Package 20 | from poetry.installation.operations.operation import Operation 21 | from poetry.poetry import Poetry 22 | from tomlkit.toml_document import TOMLDocument 23 | 24 | 25 | class PoetryTestApplication(Application): 26 | def __init__(self, poetry: Poetry) -> None: 27 | super().__init__() 28 | self._poetry = poetry 29 | 30 | def reset_poetry(self) -> None: 31 | poetry = self._poetry 32 | assert poetry 33 | self._poetry = Factory().create_poetry(poetry.file.path.parent) 34 | self._poetry.set_pool(poetry.pool) 35 | self._poetry.set_config(poetry.config) 36 | self._poetry.set_locker( 37 | TestLocker(poetry.locker.lock, self._poetry.local_config) 38 | ) 39 | 40 | 41 | class TestLocker(Locker): 42 | def __init__(self, lock: Path, local_config: dict[str, Any]) -> None: 43 | super().__init__(lock, local_config) 44 | self._locked = False 45 | self._write = False 46 | self._contains_credential = False 47 | 48 | def write(self, write: bool = True) -> None: 49 | self._write = write 50 | 51 | def is_locked(self) -> bool: 52 | return self._locked 53 | 54 | def locked(self, is_locked: bool = True) -> TestLocker: 55 | self._locked = is_locked 56 | 57 | return self 58 | 59 | def mock_lock_data(self, data: dict[str, Any]) -> None: 60 | self.locked() 61 | 62 | self._lock_data = data 63 | 64 | def is_fresh(self) -> bool: 65 | return True 66 | 67 | def _write_lock_data(self, data: TOMLDocument) -> None: 68 | if self._write: 69 | super()._write_lock_data(data) 70 | self._locked = True 71 | return 72 | 73 | self._lock_data = data 74 | 75 | 76 | class TestExecutor(Executor): 77 | def __init__(self, *args: Any, **kwargs: Any) -> None: 78 | super().__init__(*args, **kwargs) 79 | 80 | self._installs: list[Package] = [] 81 | self._updates: list[Package] = [] 82 | self._uninstalls: list[Package] = [] 83 | 84 | @property 85 | def installations(self) -> list[Package]: 86 | return self._installs 87 | 88 | @property 89 | def updates(self) -> list[Package]: 90 | return self._updates 91 | 92 | @property 93 | def removals(self) -> list[Package]: 94 | return self._uninstalls 95 | 96 | def _do_execute_operation(self, operation: Operation) -> int: 97 | super()._do_execute_operation(operation) 98 | 99 | if not operation.skipped: 100 | getattr(self, f"_{operation.job_type}s").append(operation.package) 101 | 102 | return 0 103 | 104 | def _execute_install(self, operation: Operation) -> int: 105 | return 0 106 | 107 | def _execute_update(self, operation: Operation) -> int: 108 | return 0 109 | 110 | def _execute_remove(self, operation: Operation) -> int: 111 | return 0 112 | 113 | 114 | @contextmanager 115 | def as_cwd(path: Path) -> Iterator[Path]: 116 | old_cwd = os.getcwd() 117 | os.chdir(path) 118 | try: 119 | yield path 120 | finally: 121 | os.chdir(old_cwd) 122 | -------------------------------------------------------------------------------- /tests/markers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from poetry.core.version.markers import MarkerUnion 4 | from poetry.core.version.markers import parse_marker 5 | 6 | 7 | MARKER_WIN32 = parse_marker('sys_platform == "win32"') 8 | MARKER_WINDOWS = parse_marker('platform_system == "Windows"') 9 | MARKER_LINUX = parse_marker('sys_platform == "linux"') 10 | MARKER_DARWIN = parse_marker('sys_platform == "darwin"') 11 | 12 | MARKER_CPYTHON = parse_marker('implementation_name == "cpython"') 13 | 14 | MARKER_PY27 = parse_marker('python_version == "2.7"') 15 | 16 | MARKER_PY36 = parse_marker('python_version >= "3.6" and python_version < "4.0"') 17 | MARKER_PY36_38 = parse_marker('python_version >= "3.6" and python_version < "3.8"') 18 | MARKER_PY36_PY362 = parse_marker( 19 | 'python_version >= "3.6" and python_full_version < "3.6.2"' 20 | ) 21 | MARKER_PY36_PY362_ALT = parse_marker( 22 | 'python_full_version < "3.6.2" and python_version == "3.6"' 23 | ) 24 | MARKER_PY362_PY40 = parse_marker( 25 | 'python_full_version >= "3.6.2" and python_version < "4.0"' 26 | ) 27 | MARKER_PY36_ONLY = parse_marker('python_version == "3.6"') 28 | 29 | MARKER_PY37 = parse_marker('python_version >= "3.7" and python_version < "4.0"') 30 | 31 | MARKER_PY = MarkerUnion(MARKER_PY27, MARKER_PY36) 32 | 33 | MARKER_PY_WIN32 = MARKER_PY.intersect(MARKER_WIN32) 34 | MARKER_PY_WINDOWS = MARKER_PY.intersect(MARKER_WINDOWS) 35 | MARKER_PY_LINUX = MARKER_PY.intersect(MARKER_LINUX) 36 | MARKER_PY_DARWIN = MARKER_PY.intersect(MARKER_DARWIN) 37 | -------------------------------------------------------------------------------- /tests/test_exporter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from typing import Any 5 | 6 | import pytest 7 | 8 | from cleo.io.buffered_io import BufferedIO 9 | from cleo.io.null_io import NullIO 10 | from poetry.core.constraints.version import Version 11 | from poetry.core.packages.dependency import Dependency 12 | from poetry.core.packages.dependency_group import MAIN_GROUP 13 | from poetry.core.version.markers import MarkerUnion 14 | from poetry.core.version.markers import parse_marker 15 | from poetry.factory import Factory 16 | from poetry.packages import Locker as BaseLocker 17 | from poetry.repositories.legacy_repository import LegacyRepository 18 | from poetry.repositories.repository_pool import Priority 19 | 20 | from poetry_plugin_export.exporter import Exporter 21 | from poetry_plugin_export.walker import DependencyWalkerError 22 | from tests.markers import MARKER_CPYTHON 23 | from tests.markers import MARKER_DARWIN 24 | from tests.markers import MARKER_LINUX 25 | from tests.markers import MARKER_PY 26 | from tests.markers import MARKER_PY27 27 | from tests.markers import MARKER_PY36 28 | from tests.markers import MARKER_PY36_38 29 | from tests.markers import MARKER_PY36_ONLY 30 | from tests.markers import MARKER_PY36_PY362 31 | from tests.markers import MARKER_PY36_PY362_ALT 32 | from tests.markers import MARKER_PY37 33 | from tests.markers import MARKER_PY362_PY40 34 | from tests.markers import MARKER_PY_DARWIN 35 | from tests.markers import MARKER_PY_LINUX 36 | from tests.markers import MARKER_PY_WIN32 37 | from tests.markers import MARKER_PY_WINDOWS 38 | from tests.markers import MARKER_WIN32 39 | from tests.markers import MARKER_WINDOWS 40 | 41 | 42 | if TYPE_CHECKING: 43 | from collections.abc import Collection 44 | from pathlib import Path 45 | 46 | from packaging.utils import NormalizedName 47 | from poetry.poetry import Poetry 48 | 49 | from tests.conftest import Config 50 | 51 | 52 | class Locker(BaseLocker): 53 | def __init__(self, fixture_root: Path) -> None: 54 | super().__init__(fixture_root / "poetry.lock", {}) 55 | self._locked = True 56 | 57 | def locked(self, is_locked: bool = True) -> Locker: 58 | self._locked = is_locked 59 | 60 | return self 61 | 62 | def mock_lock_data(self, data: dict[str, Any]) -> None: 63 | self._lock_data = data 64 | 65 | def is_locked(self) -> bool: 66 | return self._locked 67 | 68 | def is_fresh(self) -> bool: 69 | return True 70 | 71 | def _get_content_hash(self) -> str: 72 | return "123456789" 73 | 74 | 75 | @pytest.fixture 76 | def locker(fixture_root: Path) -> Locker: 77 | return Locker(fixture_root) 78 | 79 | 80 | @pytest.fixture 81 | def poetry(fixture_root: Path, locker: Locker) -> Poetry: 82 | p = Factory().create_poetry(fixture_root / "sample_project") 83 | p._locker = locker 84 | 85 | return p 86 | 87 | 88 | def set_package_requires( 89 | poetry: Poetry, 90 | skip: set[str] | None = None, 91 | dev: set[str] | None = None, 92 | markers: dict[str, str] | None = None, 93 | ) -> None: 94 | skip = skip or set() 95 | dev = dev or set() 96 | packages = poetry.locker.locked_repository().packages 97 | package = poetry.package.with_dependency_groups([], only=True) 98 | for pkg in packages: 99 | if pkg.name not in skip: 100 | dep = pkg.to_dependency() 101 | if pkg.name in dev: 102 | dep._groups = frozenset(["dev"]) 103 | if markers and pkg.name in markers: 104 | dep._marker = parse_marker(markers[pkg.name]) 105 | package.add_dependency(dep) 106 | 107 | poetry._package = package 108 | 109 | 110 | def fix_lock_data(lock_data: dict[str, Any]) -> None: 111 | if Version.parse(lock_data["metadata"]["lock-version"]) >= Version.parse("2.1"): 112 | for locked_package in lock_data["package"]: 113 | locked_package["groups"] = ["main"] 114 | locked_package["files"] = lock_data["metadata"]["files"][ 115 | locked_package["name"] 116 | ] 117 | del lock_data["metadata"]["files"] 118 | 119 | 120 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 121 | def test_exporter_can_export_requirements_txt_with_standard_packages( 122 | tmp_path: Path, poetry: Poetry, lock_version: str 123 | ) -> None: 124 | lock_data = { 125 | "package": [ 126 | { 127 | "name": "foo", 128 | "version": "1.2.3", 129 | "optional": False, 130 | "python-versions": "*", 131 | }, 132 | { 133 | "name": "bar", 134 | "version": "4.5.6", 135 | "optional": False, 136 | "python-versions": "*", 137 | }, 138 | ], 139 | "metadata": { 140 | "lock-version": lock_version, 141 | "python-versions": "*", 142 | "content-hash": "123456789", 143 | "files": {"foo": [], "bar": []}, 144 | }, 145 | } 146 | fix_lock_data(lock_data) 147 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 148 | set_package_requires(poetry) 149 | 150 | exporter = Exporter(poetry, NullIO()) 151 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 152 | 153 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 154 | content = f.read() 155 | 156 | expected = f"""\ 157 | bar==4.5.6 ; {MARKER_PY} 158 | foo==1.2.3 ; {MARKER_PY} 159 | """ 160 | 161 | assert content == expected 162 | 163 | 164 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 165 | def test_exporter_can_export_requirements_txt_with_standard_packages_and_markers( 166 | tmp_path: Path, poetry: Poetry, lock_version: str 167 | ) -> None: 168 | lock_data: dict[str, Any] = { 169 | "package": [ 170 | { 171 | "name": "foo", 172 | "version": "1.2.3", 173 | "optional": False, 174 | "python-versions": "*", 175 | }, 176 | { 177 | "name": "bar", 178 | "version": "4.5.6", 179 | "optional": False, 180 | "python-versions": "*", 181 | }, 182 | { 183 | "name": "baz", 184 | "version": "7.8.9", 185 | "optional": False, 186 | "python-versions": "*", 187 | }, 188 | ], 189 | "metadata": { 190 | "lock-version": lock_version, 191 | "python-versions": "*", 192 | "content-hash": "123456789", 193 | "files": {"foo": [], "bar": [], "baz": []}, 194 | }, 195 | } 196 | fix_lock_data(lock_data) 197 | if lock_version == "2.1": 198 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 199 | lock_data["package"][2]["markers"] = "sys_platform == 'win32'" 200 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 201 | markers = { 202 | "foo": "python_version < '3.7'", 203 | "bar": "extra =='foo'", 204 | "baz": "sys_platform == 'win32'", 205 | } 206 | set_package_requires(poetry, markers=markers) 207 | 208 | exporter = Exporter(poetry, NullIO()) 209 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 210 | 211 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 212 | content = f.read() 213 | 214 | expected = f"""\ 215 | bar==4.5.6 ; {MARKER_PY} 216 | baz==7.8.9 ; {MARKER_PY_WIN32} 217 | foo==1.2.3 ; {MarkerUnion(MARKER_PY27, MARKER_PY36_ONLY)} 218 | """ 219 | 220 | assert content == expected 221 | 222 | 223 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 224 | def test_exporter_can_export_requirements_txt_poetry( 225 | tmp_path: Path, poetry: Poetry, lock_version: str 226 | ) -> None: 227 | """Regression test for #3254""" 228 | 229 | lock_data: dict[str, Any] = { 230 | "package": [ 231 | { 232 | "name": "poetry", 233 | "version": "1.1.4", 234 | "optional": False, 235 | "python-versions": "*", 236 | "dependencies": {"keyring": "*"}, 237 | }, 238 | { 239 | "name": "junit-xml", 240 | "version": "1.9", 241 | "optional": False, 242 | "python-versions": "*", 243 | "dependencies": {"six": "*"}, 244 | }, 245 | { 246 | "name": "keyring", 247 | "version": "21.8.0", 248 | "optional": False, 249 | "python-versions": "*", 250 | "dependencies": { 251 | "SecretStorage": { 252 | "version": "*", 253 | "markers": "sys_platform == 'linux'", 254 | } 255 | }, 256 | }, 257 | { 258 | "name": "secretstorage", 259 | "version": "3.3.0", 260 | "optional": False, 261 | "python-versions": "*", 262 | "dependencies": {"cryptography": "*"}, 263 | }, 264 | { 265 | "name": "cryptography", 266 | "version": "3.2", 267 | "optional": False, 268 | "python-versions": "*", 269 | "dependencies": {"six": "*"}, 270 | }, 271 | { 272 | "name": "six", 273 | "version": "1.15.0", 274 | "optional": False, 275 | "python-versions": "*", 276 | }, 277 | ], 278 | "metadata": { 279 | "lock-version": lock_version, 280 | "python-versions": "*", 281 | "content-hash": "123456789", 282 | "files": { 283 | "poetry": [], 284 | "keyring": [], 285 | "secretstorage": [], 286 | "cryptography": [], 287 | "six": [], 288 | "junit-xml": [], 289 | }, 290 | }, 291 | } 292 | fix_lock_data(lock_data) 293 | if lock_version == "2.1": 294 | lock_data["package"][3]["markers"] = "sys_platform == 'linux'" 295 | lock_data["package"][4]["markers"] = "sys_platform == 'linux'" 296 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 297 | set_package_requires( 298 | poetry, skip={"keyring", "secretstorage", "cryptography", "six"} 299 | ) 300 | 301 | exporter = Exporter(poetry, NullIO()) 302 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 303 | 304 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 305 | content = f.read() 306 | 307 | # The dependency graph: 308 | # junit-xml 1.9 Creates JUnit XML test result documents that can be read by tools 309 | # └── six * such as Jenkins 310 | # poetry 1.1.4 Python dependency management and packaging made easy. 311 | # ├── keyring >=21.2.0,<22.0.0 312 | # │ ├── importlib-metadata >=1 313 | # │ │ └── zipp >=0.5 314 | # │ ├── jeepney >=0.4.2 315 | # │ ├── pywin32-ctypes <0.1.0 || >0.1.0,<0.1.1 || >0.1.1 316 | # │ └── secretstorage >=3.2 -- On linux only 317 | # │ ├── cryptography >=2.0 318 | # │ │ └── six >=1.4.1 319 | # │ └── jeepney >=0.6 (circular dependency aborted here) 320 | expected = { 321 | "poetry": Dependency.create_from_pep_508(f"poetry==1.1.4; {MARKER_PY}"), 322 | "junit-xml": Dependency.create_from_pep_508(f"junit-xml==1.9 ; {MARKER_PY}"), 323 | "keyring": Dependency.create_from_pep_508(f"keyring==21.8.0 ; {MARKER_PY}"), 324 | "secretstorage": Dependency.create_from_pep_508( 325 | f"secretstorage==3.3.0 ; {MARKER_PY_LINUX}" 326 | ), 327 | "cryptography": Dependency.create_from_pep_508( 328 | f"cryptography==3.2 ; {MARKER_PY_LINUX}" 329 | ), 330 | "six": Dependency.create_from_pep_508( 331 | f"six==1.15.0 ; {MARKER_PY.union(MARKER_PY_LINUX)}" 332 | ), 333 | } 334 | 335 | for line in content.strip().split("\n"): 336 | dependency = Dependency.create_from_pep_508(line) 337 | assert dependency.name in expected 338 | expected_dependency = expected.pop(dependency.name) 339 | assert dependency == expected_dependency 340 | assert dependency.marker == expected_dependency.marker 341 | 342 | 343 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 344 | def test_exporter_can_export_requirements_txt_pyinstaller( 345 | tmp_path: Path, poetry: Poetry, lock_version: str 346 | ) -> None: 347 | """Regression test for #3254""" 348 | 349 | lock_data: dict[str, Any] = { 350 | "package": [ 351 | { 352 | "name": "pyinstaller", 353 | "version": "4.0", 354 | "optional": False, 355 | "python-versions": "*", 356 | "dependencies": { 357 | "altgraph": "*", 358 | "macholib": { 359 | "version": "*", 360 | "markers": "sys_platform == 'darwin'", 361 | }, 362 | }, 363 | }, 364 | { 365 | "name": "altgraph", 366 | "version": "0.17", 367 | "optional": False, 368 | "python-versions": "*", 369 | }, 370 | { 371 | "name": "macholib", 372 | "version": "1.8", 373 | "optional": False, 374 | "python-versions": "*", 375 | "dependencies": {"altgraph": ">=0.15"}, 376 | }, 377 | ], 378 | "metadata": { 379 | "lock-version": lock_version, 380 | "python-versions": "*", 381 | "content-hash": "123456789", 382 | "files": {"pyinstaller": [], "altgraph": [], "macholib": []}, 383 | }, 384 | } 385 | fix_lock_data(lock_data) 386 | if lock_version == "2.1": 387 | lock_data["package"][2]["markers"] = "sys_platform == 'darwin'" 388 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 389 | set_package_requires(poetry, skip={"altgraph", "macholib"}) 390 | 391 | exporter = Exporter(poetry, NullIO()) 392 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 393 | 394 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 395 | content = f.read() 396 | 397 | # Rationale for the results: 398 | # * PyInstaller has an explicit dependency on altgraph, so it must always be 399 | # installed. 400 | # * PyInstaller requires macholib on Darwin, which in turn requires altgraph. 401 | # The dependency graph: 402 | # pyinstaller 4.0 PyInstaller bundles a Python application and all its 403 | # ├── altgraph * dependencies into a single package. 404 | # ├── macholib >=1.8 -- only on Darwin 405 | # │ └── altgraph >=0.15 406 | expected = { 407 | "pyinstaller": Dependency.create_from_pep_508( 408 | f"pyinstaller==4.0 ; {MARKER_PY}" 409 | ), 410 | "altgraph": Dependency.create_from_pep_508( 411 | f"altgraph==0.17 ; {MARKER_PY.union(MARKER_PY_DARWIN)}" 412 | ), 413 | "macholib": Dependency.create_from_pep_508( 414 | f"macholib==1.8 ; {MARKER_PY_DARWIN}" 415 | ), 416 | } 417 | 418 | for line in content.strip().split("\n"): 419 | dependency = Dependency.create_from_pep_508(line) 420 | assert dependency.name in expected 421 | expected_dependency = expected.pop(dependency.name) 422 | assert dependency == expected_dependency 423 | assert dependency.marker == expected_dependency.marker 424 | 425 | 426 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 427 | def test_exporter_can_export_requirements_txt_with_nested_packages_and_markers( 428 | tmp_path: Path, poetry: Poetry, lock_version: str 429 | ) -> None: 430 | lock_data: dict[str, Any] = { 431 | "package": [ 432 | { 433 | "name": "a", 434 | "version": "1.2.3", 435 | "optional": False, 436 | "python-versions": "*", 437 | "dependencies": { 438 | "b": { 439 | "version": ">=0.0.0", 440 | "markers": "platform_system == 'Windows'", 441 | }, 442 | "c": { 443 | "version": ">=0.0.0", 444 | "markers": "sys_platform == 'win32'", 445 | }, 446 | }, 447 | }, 448 | { 449 | "name": "b", 450 | "version": "4.5.6", 451 | "optional": False, 452 | "python-versions": "*", 453 | "dependencies": {"d": ">=0.0.0"}, 454 | }, 455 | { 456 | "name": "c", 457 | "version": "7.8.9", 458 | "optional": False, 459 | "python-versions": "*", 460 | "dependencies": {"d": ">=0.0.0"}, 461 | }, 462 | { 463 | "name": "d", 464 | "version": "0.0.1", 465 | "optional": False, 466 | "python-versions": "*", 467 | }, 468 | ], 469 | "metadata": { 470 | "lock-version": lock_version, 471 | "python-versions": "*", 472 | "content-hash": "123456789", 473 | "files": {"a": [], "b": [], "c": [], "d": []}, 474 | }, 475 | } 476 | fix_lock_data(lock_data) 477 | if lock_version == "2.1": 478 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 479 | lock_data["package"][1]["markers"] = ( 480 | "python_version < '3.7' and platform_system == 'Windows'" 481 | ) 482 | lock_data["package"][2]["markers"] = ( 483 | "python_version < '3.7' and sys_platform == 'win32'" 484 | ) 485 | lock_data["package"][3]["markers"] = ( 486 | "python_version < '3.7' and platform_system == 'Windows'" 487 | " or python_version < '3.7' and sys_platform == 'win32'" 488 | ) 489 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 490 | set_package_requires( 491 | poetry, skip={"b", "c", "d"}, markers={"a": "python_version < '3.7'"} 492 | ) 493 | 494 | exporter = Exporter(poetry, NullIO()) 495 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 496 | 497 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 498 | content = f.read() 499 | 500 | marker_py = MarkerUnion(MARKER_PY27, MARKER_PY36_ONLY) 501 | marker_py_win32 = marker_py.intersect(MARKER_WIN32) 502 | marker_py_windows = marker_py.intersect(MARKER_WINDOWS) 503 | 504 | expected = { 505 | "a": Dependency.create_from_pep_508(f"a==1.2.3 ; {marker_py}"), 506 | "b": Dependency.create_from_pep_508(f"b==4.5.6 ; {marker_py_windows}"), 507 | "c": Dependency.create_from_pep_508(f"c==7.8.9 ; {marker_py_win32}"), 508 | "d": Dependency.create_from_pep_508( 509 | f"d==0.0.1 ; {marker_py_windows.union(marker_py_win32)}" 510 | ), 511 | } 512 | 513 | for line in content.strip().split("\n"): 514 | dependency = Dependency.create_from_pep_508(line) 515 | assert dependency.name in expected 516 | expected_dependency = expected.pop(dependency.name) 517 | assert dependency == expected_dependency 518 | assert dependency.marker == expected_dependency.marker 519 | 520 | assert expected == {} 521 | 522 | 523 | @pytest.mark.parametrize( 524 | ["dev", "lines"], 525 | [ 526 | ( 527 | False, 528 | [f"a==1.2.3 ; {MarkerUnion(MARKER_PY27, MARKER_PY36_38)}"], 529 | ), 530 | ( 531 | True, 532 | [ 533 | f"a==1.2.3 ; {MarkerUnion(MARKER_PY27, MARKER_PY36_38.union(MARKER_PY36))}", 534 | f"b==4.5.6 ; {MARKER_PY}", 535 | ], 536 | ), 537 | ], 538 | ) 539 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 540 | def test_exporter_can_export_requirements_txt_with_nested_packages_and_markers_any( 541 | tmp_path: Path, poetry: Poetry, dev: bool, lines: list[str], lock_version: str 542 | ) -> None: 543 | lock_data: dict[str, Any] = { 544 | "package": [ 545 | { 546 | "name": "a", 547 | "version": "1.2.3", 548 | "optional": False, 549 | "python-versions": "*", 550 | }, 551 | { 552 | "name": "b", 553 | "version": "4.5.6", 554 | "optional": False, 555 | "python-versions": "*", 556 | "dependencies": {"a": ">=1.2.3"}, 557 | }, 558 | ], 559 | "metadata": { 560 | "lock-version": lock_version, 561 | "python-versions": "*", 562 | "content-hash": "123456789", 563 | "files": {"a": [], "b": []}, 564 | }, 565 | } 566 | fix_lock_data(lock_data) 567 | if lock_version == "2.1": 568 | lock_data["package"][0]["groups"] = ["main", "dev"] 569 | lock_data["package"][0]["markers"] = {"main": "python_version < '3.8'"} 570 | lock_data["package"][1]["groups"] = ["dev"] 571 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 572 | 573 | root = poetry.package.with_dependency_groups([], only=True) 574 | root.add_dependency( 575 | Factory.create_dependency( 576 | name="a", constraint={"version": "^1.2.3", "python": "<3.8"} 577 | ) 578 | ) 579 | root.add_dependency( 580 | Factory.create_dependency( 581 | name="b", constraint={"version": "^4.5.6"}, groups=["dev"] 582 | ) 583 | ) 584 | poetry._package = root 585 | 586 | exporter = Exporter(poetry, NullIO()) 587 | if dev: 588 | exporter.only_groups([MAIN_GROUP, "dev"]) 589 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 590 | 591 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 592 | content = f.read() 593 | 594 | assert content.strip() == "\n".join(lines) 595 | 596 | 597 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 598 | def test_exporter_can_export_requirements_txt_with_standard_packages_and_hashes( 599 | tmp_path: Path, poetry: Poetry, lock_version: str 600 | ) -> None: 601 | lock_data: dict[str, Any] = { 602 | "package": [ 603 | { 604 | "name": "foo", 605 | "version": "1.2.3", 606 | "optional": False, 607 | "python-versions": "*", 608 | }, 609 | { 610 | "name": "bar", 611 | "version": "4.5.6", 612 | "optional": False, 613 | "python-versions": "*", 614 | }, 615 | ], 616 | "metadata": { 617 | "lock-version": lock_version, 618 | "python-versions": "*", 619 | "content-hash": "123456789", 620 | "files": { 621 | "foo": [{"name": "foo.whl", "hash": "12345"}], 622 | "bar": [{"name": "bar.whl", "hash": "67890"}], 623 | }, 624 | }, 625 | } 626 | fix_lock_data(lock_data) 627 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 628 | set_package_requires(poetry) 629 | 630 | exporter = Exporter(poetry, NullIO()) 631 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 632 | 633 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 634 | content = f.read() 635 | 636 | expected = f"""\ 637 | bar==4.5.6 ; {MARKER_PY} \\ 638 | --hash=sha256:67890 639 | foo==1.2.3 ; {MARKER_PY} \\ 640 | --hash=sha256:12345 641 | """ 642 | 643 | assert content == expected 644 | 645 | 646 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 647 | def test_exporter_can_export_requirements_txt_with_standard_packages_and_sorted_hashes( 648 | tmp_path: Path, poetry: Poetry, lock_version: str 649 | ) -> None: 650 | lock_data = { 651 | "package": [ 652 | { 653 | "name": "foo", 654 | "version": "1.2.3", 655 | "optional": False, 656 | "python-versions": "*", 657 | }, 658 | { 659 | "name": "bar", 660 | "version": "4.5.6", 661 | "optional": False, 662 | "python-versions": "*", 663 | }, 664 | ], 665 | "metadata": { 666 | "lock-version": lock_version, 667 | "python-versions": "*", 668 | "content-hash": "123456789", 669 | "files": { 670 | "foo": [ 671 | {"name": "foo1.whl", "hash": "67890"}, 672 | {"name": "foo2.whl", "hash": "12345"}, 673 | ], 674 | "bar": [ 675 | {"name": "bar1.whl", "hash": "67890"}, 676 | {"name": "bar2.whl", "hash": "12345"}, 677 | ], 678 | }, 679 | }, 680 | } 681 | fix_lock_data(lock_data) 682 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 683 | set_package_requires(poetry) 684 | 685 | exporter = Exporter(poetry, NullIO()) 686 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 687 | 688 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 689 | content = f.read() 690 | 691 | expected = f"""\ 692 | bar==4.5.6 ; {MARKER_PY} \\ 693 | --hash=sha256:12345 \\ 694 | --hash=sha256:67890 695 | foo==1.2.3 ; {MARKER_PY} \\ 696 | --hash=sha256:12345 \\ 697 | --hash=sha256:67890 698 | """ 699 | 700 | assert content == expected 701 | 702 | 703 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 704 | def test_exporter_can_export_requirements_txt_with_standard_packages_and_hashes_disabled( 705 | tmp_path: Path, poetry: Poetry, lock_version: str 706 | ) -> None: 707 | lock_data = { 708 | "package": [ 709 | { 710 | "name": "foo", 711 | "version": "1.2.3", 712 | "optional": False, 713 | "python-versions": "*", 714 | }, 715 | { 716 | "name": "bar", 717 | "version": "4.5.6", 718 | "optional": False, 719 | "python-versions": "*", 720 | }, 721 | ], 722 | "metadata": { 723 | "lock-version": lock_version, 724 | "python-versions": "*", 725 | "content-hash": "123456789", 726 | "files": { 727 | "foo": [{"name": "foo.whl", "hash": "12345"}], 728 | "bar": [{"name": "bar.whl", "hash": "67890"}], 729 | }, 730 | }, 731 | } 732 | fix_lock_data(lock_data) 733 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 734 | set_package_requires(poetry) 735 | 736 | exporter = Exporter(poetry, NullIO()) 737 | exporter.with_hashes(False) 738 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 739 | 740 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 741 | content = f.read() 742 | 743 | expected = f"""\ 744 | bar==4.5.6 ; {MARKER_PY} 745 | foo==1.2.3 ; {MARKER_PY} 746 | """ 747 | 748 | assert content == expected 749 | 750 | 751 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 752 | def test_exporter_exports_requirements_txt_without_dev_packages_by_default( 753 | tmp_path: Path, poetry: Poetry, lock_version: str 754 | ) -> None: 755 | lock_data: dict[str, Any] = { 756 | "package": [ 757 | { 758 | "name": "foo", 759 | "version": "1.2.3", 760 | "optional": False, 761 | "python-versions": "*", 762 | }, 763 | { 764 | "name": "bar", 765 | "version": "4.5.6", 766 | "optional": False, 767 | "python-versions": "*", 768 | }, 769 | ], 770 | "metadata": { 771 | "lock-version": lock_version, 772 | "python-versions": "*", 773 | "content-hash": "123456789", 774 | "files": { 775 | "foo": [{"name": "foo.whl", "hash": "12345"}], 776 | "bar": [{"name": "bar.whl", "hash": "67890"}], 777 | }, 778 | }, 779 | } 780 | fix_lock_data(lock_data) 781 | if lock_version == "2.1": 782 | lock_data["package"][1]["groups"] = ["dev"] 783 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 784 | set_package_requires(poetry, dev={"bar"}) 785 | 786 | exporter = Exporter(poetry, NullIO()) 787 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 788 | 789 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 790 | content = f.read() 791 | 792 | expected = f"""\ 793 | foo==1.2.3 ; {MARKER_PY} \\ 794 | --hash=sha256:12345 795 | """ 796 | 797 | assert content == expected 798 | 799 | 800 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 801 | def test_exporter_exports_requirements_txt_with_dev_packages_if_opted_in( 802 | tmp_path: Path, poetry: Poetry, lock_version: str 803 | ) -> None: 804 | lock_data: dict[str, Any] = { 805 | "package": [ 806 | { 807 | "name": "foo", 808 | "version": "1.2.3", 809 | "optional": False, 810 | "python-versions": "*", 811 | }, 812 | { 813 | "name": "bar", 814 | "version": "4.5.6", 815 | "optional": False, 816 | "python-versions": "*", 817 | }, 818 | ], 819 | "metadata": { 820 | "lock-version": lock_version, 821 | "python-versions": "*", 822 | "content-hash": "123456789", 823 | "files": { 824 | "foo": [{"name": "foo.whl", "hash": "12345"}], 825 | "bar": [{"name": "bar.whl", "hash": "67890"}], 826 | }, 827 | }, 828 | } 829 | fix_lock_data(lock_data) 830 | if lock_version == "2.1": 831 | lock_data["package"][1]["groups"] = ["dev"] 832 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 833 | set_package_requires(poetry, dev={"bar"}) 834 | 835 | exporter = Exporter(poetry, NullIO()) 836 | exporter.only_groups([MAIN_GROUP, "dev"]) 837 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 838 | 839 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 840 | content = f.read() 841 | 842 | expected = f"""\ 843 | bar==4.5.6 ; {MARKER_PY} \\ 844 | --hash=sha256:67890 845 | foo==1.2.3 ; {MARKER_PY} \\ 846 | --hash=sha256:12345 847 | """ 848 | 849 | assert content == expected 850 | 851 | 852 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 853 | def test_exporter_exports_requirements_txt_without_groups_if_set_explicitly( 854 | tmp_path: Path, poetry: Poetry, lock_version: str 855 | ) -> None: 856 | lock_data: dict[str, Any] = { 857 | "package": [ 858 | { 859 | "name": "foo", 860 | "version": "1.2.3", 861 | "optional": False, 862 | "python-versions": "*", 863 | }, 864 | { 865 | "name": "bar", 866 | "version": "4.5.6", 867 | "optional": False, 868 | "python-versions": "*", 869 | }, 870 | ], 871 | "metadata": { 872 | "lock-version": lock_version, 873 | "python-versions": "*", 874 | "content-hash": "123456789", 875 | "files": { 876 | "foo": [{"name": "foo.whl", "hash": "12345"}], 877 | "bar": [{"name": "bar.whl", "hash": "67890"}], 878 | }, 879 | }, 880 | } 881 | fix_lock_data(lock_data) 882 | if lock_version == "2.1": 883 | lock_data["package"][1]["groups"] = ["dev"] 884 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 885 | set_package_requires(poetry, dev={"bar"}) 886 | 887 | exporter = Exporter(poetry, NullIO()) 888 | exporter.only_groups([]) 889 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 890 | 891 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 892 | content = f.read() 893 | 894 | assert content == "\n" 895 | 896 | 897 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 898 | def test_exporter_exports_requirements_txt_without_optional_packages( 899 | tmp_path: Path, poetry: Poetry, lock_version: str 900 | ) -> None: 901 | lock_data: dict[str, Any] = { 902 | "package": [ 903 | { 904 | "name": "foo", 905 | "version": "1.2.3", 906 | "optional": False, 907 | "python-versions": "*", 908 | }, 909 | { 910 | "name": "bar", 911 | "version": "4.5.6", 912 | "optional": True, 913 | "python-versions": "*", 914 | }, 915 | ], 916 | "metadata": { 917 | "lock-version": lock_version, 918 | "python-versions": "*", 919 | "content-hash": "123456789", 920 | "files": { 921 | "foo": [{"name": "foo.whl", "hash": "12345"}], 922 | "bar": [{"name": "bar.whl", "hash": "67890"}], 923 | }, 924 | }, 925 | } 926 | fix_lock_data(lock_data) 927 | if lock_version == "2.1": 928 | lock_data["package"][1]["groups"] = ["dev"] 929 | lock_data["package"][1]["markers"] = 'extra == "feature-bar"' 930 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 931 | set_package_requires(poetry, dev={"bar"}) 932 | 933 | exporter = Exporter(poetry, NullIO()) 934 | exporter.only_groups([MAIN_GROUP, "dev"]) 935 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 936 | 937 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 938 | content = f.read() 939 | 940 | expected = f"""\ 941 | foo==1.2.3 ; {MARKER_PY} \\ 942 | --hash=sha256:12345 943 | """ 944 | 945 | assert content == expected 946 | 947 | 948 | @pytest.mark.parametrize( 949 | ["extras", "lines"], 950 | [ 951 | ( 952 | ["feature-bar"], 953 | [ 954 | f"bar==4.5.6 ; {MARKER_PY}", 955 | f"foo==1.2.3 ; {MARKER_PY}", 956 | f"spam==0.1.0 ; {MARKER_PY}", 957 | ], 958 | ), 959 | ], 960 | ) 961 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 962 | def test_exporter_exports_requirements_txt_with_optional_packages( 963 | tmp_path: Path, 964 | poetry: Poetry, 965 | extras: Collection[NormalizedName], 966 | lines: list[str], 967 | lock_version: str, 968 | ) -> None: 969 | lock_data: dict[str, Any] = { 970 | "package": [ 971 | { 972 | "name": "foo", 973 | "version": "1.2.3", 974 | "optional": False, 975 | "python-versions": "*", 976 | }, 977 | { 978 | "name": "bar", 979 | "version": "4.5.6", 980 | "optional": True, 981 | "python-versions": "*", 982 | "dependencies": {"spam": ">=0.1"}, 983 | }, 984 | { 985 | "name": "spam", 986 | "version": "0.1.0", 987 | "optional": True, 988 | "python-versions": "*", 989 | }, 990 | ], 991 | "metadata": { 992 | "lock-version": lock_version, 993 | "python-versions": "*", 994 | "content-hash": "123456789", 995 | "files": { 996 | "foo": [{"name": "foo.whl", "hash": "12345"}], 997 | "bar": [{"name": "bar.whl", "hash": "67890"}], 998 | "spam": [{"name": "spam.whl", "hash": "abcde"}], 999 | }, 1000 | }, 1001 | "extras": {"feature_bar": ["bar"]}, 1002 | } 1003 | fix_lock_data(lock_data) 1004 | if lock_version == "2.1": 1005 | lock_data["package"][1]["markers"] = 'extra == "feature-bar"' 1006 | lock_data["package"][2]["markers"] = 'extra == "feature-bar"' 1007 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1008 | set_package_requires(poetry) 1009 | 1010 | exporter = Exporter(poetry, NullIO()) 1011 | exporter.only_groups([MAIN_GROUP, "dev"]) 1012 | exporter.with_hashes(False) 1013 | exporter.with_extras(extras) 1014 | exporter.export( 1015 | "requirements.txt", 1016 | tmp_path, 1017 | "requirements.txt", 1018 | ) 1019 | 1020 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1021 | content = f.read() 1022 | 1023 | expected = "\n".join(lines) 1024 | 1025 | assert content.strip() == expected 1026 | 1027 | 1028 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1029 | def test_exporter_can_export_requirements_txt_with_git_packages( 1030 | tmp_path: Path, poetry: Poetry, lock_version: str 1031 | ) -> None: 1032 | lock_data = { 1033 | "package": [ 1034 | { 1035 | "name": "foo", 1036 | "version": "1.2.3", 1037 | "optional": False, 1038 | "python-versions": "*", 1039 | "source": { 1040 | "type": "git", 1041 | "url": "https://github.com/foo/foo.git", 1042 | "reference": "123456", 1043 | "resolved_reference": "abcdef", 1044 | }, 1045 | } 1046 | ], 1047 | "metadata": { 1048 | "lock-version": lock_version, 1049 | "python-versions": "*", 1050 | "content-hash": "123456789", 1051 | "files": {"foo": []}, 1052 | }, 1053 | } 1054 | fix_lock_data(lock_data) 1055 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1056 | set_package_requires(poetry) 1057 | 1058 | exporter = Exporter(poetry, NullIO()) 1059 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1060 | 1061 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1062 | content = f.read() 1063 | 1064 | expected = f"""\ 1065 | foo @ git+https://github.com/foo/foo.git@abcdef ; {MARKER_PY} 1066 | """ 1067 | 1068 | assert content == expected 1069 | 1070 | 1071 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1072 | def test_exporter_can_export_requirements_txt_with_nested_packages( 1073 | tmp_path: Path, poetry: Poetry, lock_version: str 1074 | ) -> None: 1075 | lock_data = { 1076 | "package": [ 1077 | { 1078 | "name": "foo", 1079 | "version": "1.2.3", 1080 | "optional": False, 1081 | "python-versions": "*", 1082 | "source": { 1083 | "type": "git", 1084 | "url": "https://github.com/foo/foo.git", 1085 | "reference": "123456", 1086 | "resolved_reference": "abcdef", 1087 | }, 1088 | }, 1089 | { 1090 | "name": "bar", 1091 | "version": "4.5.6", 1092 | "optional": False, 1093 | "python-versions": "*", 1094 | "dependencies": { 1095 | "foo": { 1096 | "git": "https://github.com/foo/foo.git", 1097 | "rev": "123456", 1098 | } 1099 | }, 1100 | }, 1101 | ], 1102 | "metadata": { 1103 | "lock-version": lock_version, 1104 | "python-versions": "*", 1105 | "content-hash": "123456789", 1106 | "files": {"foo": [], "bar": []}, 1107 | }, 1108 | } 1109 | fix_lock_data(lock_data) 1110 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1111 | set_package_requires(poetry, skip={"foo"}) 1112 | 1113 | exporter = Exporter(poetry, NullIO()) 1114 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1115 | 1116 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1117 | content = f.read() 1118 | 1119 | expected = f"""\ 1120 | bar==4.5.6 ; {MARKER_PY} 1121 | foo @ git+https://github.com/foo/foo.git@abcdef ; {MARKER_PY} 1122 | """ 1123 | 1124 | assert content == expected 1125 | 1126 | 1127 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1128 | def test_exporter_can_export_requirements_txt_with_nested_packages_cyclic( 1129 | tmp_path: Path, poetry: Poetry, lock_version: str 1130 | ) -> None: 1131 | lock_data = { 1132 | "package": [ 1133 | { 1134 | "name": "foo", 1135 | "version": "1.2.3", 1136 | "optional": False, 1137 | "python-versions": "*", 1138 | "dependencies": {"bar": {"version": "4.5.6"}}, 1139 | }, 1140 | { 1141 | "name": "bar", 1142 | "version": "4.5.6", 1143 | "optional": False, 1144 | "python-versions": "*", 1145 | "dependencies": {"baz": {"version": "7.8.9"}}, 1146 | }, 1147 | { 1148 | "name": "baz", 1149 | "version": "7.8.9", 1150 | "optional": False, 1151 | "python-versions": "*", 1152 | "dependencies": {"foo": {"version": "1.2.3"}}, 1153 | }, 1154 | ], 1155 | "metadata": { 1156 | "lock-version": lock_version, 1157 | "python-versions": "*", 1158 | "content-hash": "123456789", 1159 | "files": {"foo": [], "bar": [], "baz": []}, 1160 | }, 1161 | } 1162 | fix_lock_data(lock_data) 1163 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1164 | set_package_requires(poetry, skip={"bar", "baz"}) 1165 | 1166 | exporter = Exporter(poetry, NullIO()) 1167 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1168 | 1169 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1170 | content = f.read() 1171 | 1172 | expected = f"""\ 1173 | bar==4.5.6 ; {MARKER_PY} 1174 | baz==7.8.9 ; {MARKER_PY} 1175 | foo==1.2.3 ; {MARKER_PY} 1176 | """ 1177 | 1178 | assert content == expected 1179 | 1180 | 1181 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1182 | def test_exporter_can_export_requirements_txt_with_circular_root_dependency( 1183 | tmp_path: Path, poetry: Poetry, lock_version: str 1184 | ) -> None: 1185 | lock_data = { 1186 | "package": [ 1187 | { 1188 | "name": "foo", 1189 | "version": "1.2.3", 1190 | "optional": False, 1191 | "python-versions": "*", 1192 | "dependencies": {poetry.package.pretty_name: {"version": "1.2.3"}}, 1193 | }, 1194 | ], 1195 | "metadata": { 1196 | "lock-version": lock_version, 1197 | "python-versions": "*", 1198 | "content-hash": "123456789", 1199 | "files": {"foo": []}, 1200 | }, 1201 | } 1202 | fix_lock_data(lock_data) 1203 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1204 | set_package_requires(poetry) 1205 | 1206 | exporter = Exporter(poetry, NullIO()) 1207 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1208 | 1209 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1210 | content = f.read() 1211 | 1212 | expected = f"""\ 1213 | foo==1.2.3 ; {MARKER_PY} 1214 | """ 1215 | 1216 | assert content == expected 1217 | 1218 | 1219 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1220 | def test_exporter_can_export_requirements_txt_with_nested_packages_and_multiple_markers( 1221 | tmp_path: Path, poetry: Poetry, lock_version: str 1222 | ) -> None: 1223 | lock_data: dict[str, Any] = { 1224 | "package": [ 1225 | { 1226 | "name": "foo", 1227 | "version": "1.2.3", 1228 | "optional": False, 1229 | "python-versions": "*", 1230 | "dependencies": { 1231 | "bar": [ 1232 | { 1233 | "version": ">=1.2.3,<7.8.10", 1234 | "markers": 'platform_system != "Windows"', 1235 | }, 1236 | { 1237 | "version": ">=4.5.6,<7.8.10", 1238 | "markers": 'platform_system == "Windows"', 1239 | }, 1240 | ] 1241 | }, 1242 | }, 1243 | { 1244 | "name": "bar", 1245 | "version": "7.8.9", 1246 | "optional": True, 1247 | "python-versions": "*", 1248 | "dependencies": { 1249 | "baz": { 1250 | "version": "!=10.11.12", 1251 | "markers": 'platform_system == "Windows"', 1252 | } 1253 | }, 1254 | }, 1255 | { 1256 | "name": "baz", 1257 | "version": "10.11.13", 1258 | "optional": True, 1259 | "python-versions": "*", 1260 | }, 1261 | ], 1262 | "metadata": { 1263 | "lock-version": lock_version, 1264 | "python-versions": "*", 1265 | "content-hash": "123456789", 1266 | "files": {"foo": [], "bar": [], "baz": []}, 1267 | }, 1268 | } 1269 | fix_lock_data(lock_data) 1270 | if lock_version == "2.1": 1271 | lock_data["package"][2]["markers"] = 'platform_system == "Windows"' 1272 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1273 | set_package_requires(poetry) 1274 | 1275 | exporter = Exporter(poetry, NullIO()) 1276 | exporter.with_hashes(False) 1277 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1278 | 1279 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1280 | content = f.read() 1281 | 1282 | marker_py_not_windows = MARKER_PY.intersect( 1283 | parse_marker('platform_system != "Windows"') 1284 | ) 1285 | expected = f"""\ 1286 | bar==7.8.9 ; {marker_py_not_windows.union(MARKER_PY_WINDOWS)} 1287 | baz==10.11.13 ; {MARKER_PY_WINDOWS} 1288 | foo==1.2.3 ; {MARKER_PY} 1289 | """ 1290 | 1291 | assert content == expected 1292 | 1293 | 1294 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1295 | def test_exporter_can_export_requirements_txt_with_git_packages_and_markers( 1296 | tmp_path: Path, poetry: Poetry, lock_version: str 1297 | ) -> None: 1298 | lock_data: dict[str, Any] = { 1299 | "package": [ 1300 | { 1301 | "name": "foo", 1302 | "version": "1.2.3", 1303 | "optional": False, 1304 | "python-versions": "*", 1305 | "source": { 1306 | "type": "git", 1307 | "url": "https://github.com/foo/foo.git", 1308 | "reference": "123456", 1309 | "resolved_reference": "abcdef", 1310 | }, 1311 | } 1312 | ], 1313 | "metadata": { 1314 | "lock-version": lock_version, 1315 | "python-versions": "*", 1316 | "content-hash": "123456789", 1317 | "files": {"foo": []}, 1318 | }, 1319 | } 1320 | fix_lock_data(lock_data) 1321 | if lock_version == "2.1": 1322 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 1323 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1324 | set_package_requires(poetry, markers={"foo": "python_version < '3.7'"}) 1325 | 1326 | exporter = Exporter(poetry, NullIO()) 1327 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1328 | 1329 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1330 | content = f.read() 1331 | 1332 | expected = f"""\ 1333 | foo @ git+https://github.com/foo/foo.git@abcdef ; {MARKER_PY27.union(MARKER_PY36_ONLY)} 1334 | """ 1335 | 1336 | assert content == expected 1337 | 1338 | 1339 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1340 | def test_exporter_can_export_requirements_txt_with_directory_packages( 1341 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1342 | ) -> None: 1343 | lock_data = { 1344 | "package": [ 1345 | { 1346 | "name": "foo", 1347 | "version": "1.2.3", 1348 | "optional": False, 1349 | "python-versions": "*", 1350 | "source": { 1351 | "type": "directory", 1352 | "url": "sample_project", 1353 | "reference": "", 1354 | }, 1355 | } 1356 | ], 1357 | "metadata": { 1358 | "lock-version": lock_version, 1359 | "python-versions": "*", 1360 | "content-hash": "123456789", 1361 | "files": {"foo": []}, 1362 | }, 1363 | } 1364 | fix_lock_data(lock_data) 1365 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1366 | set_package_requires(poetry) 1367 | 1368 | exporter = Exporter(poetry, NullIO()) 1369 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1370 | 1371 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1372 | content = f.read() 1373 | 1374 | expected = f"""\ 1375 | foo @ {fixture_root_uri}/sample_project ; {MARKER_PY} 1376 | """ 1377 | 1378 | assert content == expected 1379 | 1380 | 1381 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1382 | def test_exporter_can_export_requirements_txt_with_directory_packages_editable( 1383 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1384 | ) -> None: 1385 | lock_data = { 1386 | "package": [ 1387 | { 1388 | "name": "foo", 1389 | "version": "1.2.3", 1390 | "optional": False, 1391 | "python-versions": "*", 1392 | "develop": True, 1393 | "source": { 1394 | "type": "directory", 1395 | "url": "sample_project", 1396 | "reference": "", 1397 | }, 1398 | } 1399 | ], 1400 | "metadata": { 1401 | "lock-version": lock_version, 1402 | "python-versions": "*", 1403 | "content-hash": "123456789", 1404 | "files": {"foo": []}, 1405 | }, 1406 | } 1407 | fix_lock_data(lock_data) 1408 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1409 | set_package_requires(poetry) 1410 | 1411 | exporter = Exporter(poetry, NullIO()) 1412 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1413 | 1414 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1415 | content = f.read() 1416 | 1417 | expected = f"""\ 1418 | -e {fixture_root_uri}/sample_project ; {MARKER_PY} 1419 | """ 1420 | 1421 | assert content == expected 1422 | 1423 | 1424 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1425 | def test_exporter_can_export_requirements_txt_with_nested_directory_packages( 1426 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1427 | ) -> None: 1428 | lock_data = { 1429 | "package": [ 1430 | { 1431 | "name": "foo", 1432 | "version": "1.2.3", 1433 | "optional": False, 1434 | "python-versions": "*", 1435 | "source": { 1436 | "type": "directory", 1437 | "url": "sample_project", 1438 | "reference": "", 1439 | }, 1440 | }, 1441 | { 1442 | "name": "bar", 1443 | "version": "4.5.6", 1444 | "optional": False, 1445 | "python-versions": "*", 1446 | "source": { 1447 | "type": "directory", 1448 | "url": "sample_project/../project_with_nested_local/bar", 1449 | "reference": "", 1450 | }, 1451 | }, 1452 | { 1453 | "name": "baz", 1454 | "version": "7.8.9", 1455 | "optional": False, 1456 | "python-versions": "*", 1457 | "source": { 1458 | "type": "directory", 1459 | "url": "sample_project/../project_with_nested_local/bar/..", 1460 | "reference": "", 1461 | }, 1462 | }, 1463 | ], 1464 | "metadata": { 1465 | "lock-version": lock_version, 1466 | "python-versions": "*", 1467 | "content-hash": "123456789", 1468 | "files": {"foo": [], "bar": [], "baz": []}, 1469 | }, 1470 | } 1471 | fix_lock_data(lock_data) 1472 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1473 | set_package_requires(poetry) 1474 | 1475 | exporter = Exporter(poetry, NullIO()) 1476 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1477 | 1478 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1479 | content = f.read() 1480 | 1481 | expected = f"""\ 1482 | bar @ {fixture_root_uri}/project_with_nested_local/bar ; {MARKER_PY} 1483 | baz @ {fixture_root_uri}/project_with_nested_local ; {MARKER_PY} 1484 | foo @ {fixture_root_uri}/sample_project ; {MARKER_PY} 1485 | """ 1486 | 1487 | assert content == expected 1488 | 1489 | 1490 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1491 | def test_exporter_can_export_requirements_txt_with_directory_packages_and_markers( 1492 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1493 | ) -> None: 1494 | lock_data: dict[str, Any] = { 1495 | "package": [ 1496 | { 1497 | "name": "foo", 1498 | "version": "1.2.3", 1499 | "optional": False, 1500 | "python-versions": "*", 1501 | "source": { 1502 | "type": "directory", 1503 | "url": "sample_project", 1504 | "reference": "", 1505 | }, 1506 | } 1507 | ], 1508 | "metadata": { 1509 | "lock-version": lock_version, 1510 | "python-versions": "*", 1511 | "content-hash": "123456789", 1512 | "files": {"foo": []}, 1513 | }, 1514 | } 1515 | fix_lock_data(lock_data) 1516 | if lock_version == "2.1": 1517 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 1518 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1519 | set_package_requires(poetry, markers={"foo": "python_version < '3.7'"}) 1520 | 1521 | exporter = Exporter(poetry, NullIO()) 1522 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1523 | 1524 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1525 | content = f.read() 1526 | 1527 | expected = f"""\ 1528 | foo @ {fixture_root_uri}/sample_project ;\ 1529 | {MARKER_PY27.union(MARKER_PY36_ONLY)} 1530 | """ 1531 | 1532 | assert content == expected 1533 | 1534 | 1535 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1536 | def test_exporter_can_export_requirements_txt_with_file_packages( 1537 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1538 | ) -> None: 1539 | lock_data = { 1540 | "package": [ 1541 | { 1542 | "name": "foo", 1543 | "version": "1.2.3", 1544 | "optional": False, 1545 | "python-versions": "*", 1546 | "source": { 1547 | "type": "file", 1548 | "url": "distributions/demo-0.1.0.tar.gz", 1549 | "reference": "", 1550 | }, 1551 | } 1552 | ], 1553 | "metadata": { 1554 | "lock-version": lock_version, 1555 | "python-versions": "*", 1556 | "content-hash": "123456789", 1557 | "files": {"foo": []}, 1558 | }, 1559 | } 1560 | fix_lock_data(lock_data) 1561 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1562 | set_package_requires(poetry) 1563 | 1564 | exporter = Exporter(poetry, NullIO()) 1565 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1566 | 1567 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1568 | content = f.read() 1569 | 1570 | expected = f"""\ 1571 | foo @ {fixture_root_uri}/distributions/demo-0.1.0.tar.gz ;\ 1572 | {MARKER_PY} 1573 | """ 1574 | 1575 | assert content == expected 1576 | 1577 | 1578 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1579 | def test_exporter_can_export_requirements_txt_with_file_packages_and_markers( 1580 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1581 | ) -> None: 1582 | lock_data: dict[str, Any] = { 1583 | "package": [ 1584 | { 1585 | "name": "foo", 1586 | "version": "1.2.3", 1587 | "optional": False, 1588 | "python-versions": "*", 1589 | "source": { 1590 | "type": "file", 1591 | "url": "distributions/demo-0.1.0.tar.gz", 1592 | "reference": "", 1593 | }, 1594 | } 1595 | ], 1596 | "metadata": { 1597 | "lock-version": lock_version, 1598 | "python-versions": "*", 1599 | "content-hash": "123456789", 1600 | "files": {"foo": []}, 1601 | }, 1602 | } 1603 | fix_lock_data(lock_data) 1604 | if lock_version == "2.1": 1605 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 1606 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1607 | set_package_requires(poetry, markers={"foo": "python_version < '3.7'"}) 1608 | 1609 | exporter = Exporter(poetry, NullIO()) 1610 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1611 | 1612 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1613 | content = f.read() 1614 | 1615 | uri = f"{fixture_root_uri}/distributions/demo-0.1.0.tar.gz" 1616 | expected = f"""\ 1617 | foo @ {uri} ; {MARKER_PY27.union(MARKER_PY36_ONLY)} 1618 | """ 1619 | 1620 | assert content == expected 1621 | 1622 | 1623 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1624 | def test_exporter_exports_requirements_txt_with_legacy_packages( 1625 | tmp_path: Path, poetry: Poetry, lock_version: str 1626 | ) -> None: 1627 | poetry.pool.add_repository( 1628 | LegacyRepository( 1629 | "custom", 1630 | "https://example.com/simple", 1631 | ) 1632 | ) 1633 | lock_data: dict[str, Any] = { 1634 | "package": [ 1635 | { 1636 | "name": "foo", 1637 | "version": "1.2.3", 1638 | "optional": False, 1639 | "python-versions": "*", 1640 | }, 1641 | { 1642 | "name": "bar", 1643 | "version": "4.5.6", 1644 | "optional": False, 1645 | "python-versions": "*", 1646 | "source": { 1647 | "type": "legacy", 1648 | "url": "https://example.com/simple", 1649 | "reference": "", 1650 | }, 1651 | }, 1652 | ], 1653 | "metadata": { 1654 | "lock-version": lock_version, 1655 | "python-versions": "*", 1656 | "content-hash": "123456789", 1657 | "files": { 1658 | "foo": [{"name": "foo.whl", "hash": "12345"}], 1659 | "bar": [{"name": "bar.whl", "hash": "67890"}], 1660 | }, 1661 | }, 1662 | } 1663 | fix_lock_data(lock_data) 1664 | if lock_version == "2.1": 1665 | lock_data["package"][1]["groups"] = ["dev"] 1666 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1667 | set_package_requires(poetry, dev={"bar"}) 1668 | 1669 | exporter = Exporter(poetry, NullIO()) 1670 | exporter.only_groups([MAIN_GROUP, "dev"]) 1671 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1672 | 1673 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1674 | content = f.read() 1675 | 1676 | expected = f"""\ 1677 | --extra-index-url https://example.com/simple 1678 | 1679 | bar==4.5.6 ; {MARKER_PY} \\ 1680 | --hash=sha256:67890 1681 | foo==1.2.3 ; {MARKER_PY} \\ 1682 | --hash=sha256:12345 1683 | """ 1684 | 1685 | assert content == expected 1686 | 1687 | 1688 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1689 | def test_exporter_exports_requirements_txt_with_url_false( 1690 | tmp_path: Path, poetry: Poetry, lock_version: str 1691 | ) -> None: 1692 | poetry.pool.add_repository( 1693 | LegacyRepository( 1694 | "custom", 1695 | "https://example.com/simple", 1696 | ) 1697 | ) 1698 | lock_data: dict[str, Any] = { 1699 | "package": [ 1700 | { 1701 | "name": "foo", 1702 | "version": "1.2.3", 1703 | "optional": False, 1704 | "python-versions": "*", 1705 | }, 1706 | { 1707 | "name": "bar", 1708 | "version": "4.5.6", 1709 | "optional": False, 1710 | "python-versions": "*", 1711 | "source": { 1712 | "type": "legacy", 1713 | "url": "https://example.com/simple", 1714 | "reference": "", 1715 | }, 1716 | }, 1717 | ], 1718 | "metadata": { 1719 | "lock-version": lock_version, 1720 | "python-versions": "*", 1721 | "content-hash": "123456789", 1722 | "files": { 1723 | "foo": [{"name": "foo.whl", "hash": "12345"}], 1724 | "bar": [{"name": "bar.whl", "hash": "67890"}], 1725 | }, 1726 | }, 1727 | } 1728 | fix_lock_data(lock_data) 1729 | if lock_version == "2.1": 1730 | lock_data["package"][1]["groups"] = ["dev"] 1731 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1732 | set_package_requires(poetry, dev={"bar"}) 1733 | 1734 | exporter = Exporter(poetry, NullIO()) 1735 | exporter.only_groups([MAIN_GROUP, "dev"]) 1736 | exporter.with_urls(False) 1737 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1738 | 1739 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1740 | content = f.read() 1741 | 1742 | expected = f"""\ 1743 | bar==4.5.6 ; {MARKER_PY} \\ 1744 | --hash=sha256:67890 1745 | foo==1.2.3 ; {MARKER_PY} \\ 1746 | --hash=sha256:12345 1747 | """ 1748 | 1749 | assert content == expected 1750 | 1751 | 1752 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1753 | def test_exporter_exports_requirements_txt_with_legacy_packages_trusted_host( 1754 | tmp_path: Path, poetry: Poetry, lock_version: str 1755 | ) -> None: 1756 | poetry.pool.add_repository( 1757 | LegacyRepository( 1758 | "custom", 1759 | "http://example.com/simple", 1760 | ) 1761 | ) 1762 | lock_data: dict[str, Any] = { 1763 | "package": [ 1764 | { 1765 | "name": "bar", 1766 | "version": "4.5.6", 1767 | "optional": False, 1768 | "python-versions": "*", 1769 | "source": { 1770 | "type": "legacy", 1771 | "url": "http://example.com/simple", 1772 | "reference": "", 1773 | }, 1774 | }, 1775 | ], 1776 | "metadata": { 1777 | "lock-version": lock_version, 1778 | "python-versions": "*", 1779 | "content-hash": "123456789", 1780 | "files": { 1781 | "bar": [{"name": "bar.whl", "hash": "67890"}], 1782 | }, 1783 | }, 1784 | } 1785 | fix_lock_data(lock_data) 1786 | if lock_version == "2.1": 1787 | lock_data["package"][0]["groups"] = ["dev"] 1788 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1789 | set_package_requires(poetry, dev={"bar"}) 1790 | exporter = Exporter(poetry, NullIO()) 1791 | exporter.only_groups([MAIN_GROUP, "dev"]) 1792 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1793 | 1794 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1795 | content = f.read() 1796 | 1797 | expected = f"""\ 1798 | --trusted-host example.com 1799 | --extra-index-url http://example.com/simple 1800 | 1801 | bar==4.5.6 ; {MARKER_PY} \\ 1802 | --hash=sha256:67890 1803 | """ 1804 | 1805 | assert content == expected 1806 | 1807 | 1808 | @pytest.mark.parametrize( 1809 | ["dev", "expected"], 1810 | [ 1811 | ( 1812 | True, 1813 | [ 1814 | f"bar==1.2.2 ; {MARKER_PY}", 1815 | f"baz==1.2.3 ; {MARKER_PY}", 1816 | f"foo==1.2.1 ; {MARKER_PY}", 1817 | ], 1818 | ), 1819 | ( 1820 | False, 1821 | [ 1822 | f"bar==1.2.2 ; {MARKER_PY}", 1823 | f"foo==1.2.1 ; {MARKER_PY}", 1824 | ], 1825 | ), 1826 | ], 1827 | ) 1828 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1829 | def test_exporter_exports_requirements_txt_with_dev_extras( 1830 | tmp_path: Path, poetry: Poetry, dev: bool, expected: list[str], lock_version: str 1831 | ) -> None: 1832 | lock_data: dict[str, Any] = { 1833 | "package": [ 1834 | { 1835 | "name": "foo", 1836 | "version": "1.2.1", 1837 | "optional": False, 1838 | "python-versions": "*", 1839 | }, 1840 | { 1841 | "name": "bar", 1842 | "version": "1.2.2", 1843 | "optional": False, 1844 | "python-versions": "*", 1845 | "dependencies": { 1846 | "baz": { 1847 | "version": ">=0.1.0", 1848 | "optional": True, 1849 | "markers": "extra == 'baz'", 1850 | } 1851 | }, 1852 | "extras": {"baz": ["baz (>=0.1.0)"]}, 1853 | }, 1854 | { 1855 | "name": "baz", 1856 | "version": "1.2.3", 1857 | "optional": False, 1858 | "python-versions": "*", 1859 | }, 1860 | ], 1861 | "metadata": { 1862 | "lock-version": lock_version, 1863 | "python-versions": "*", 1864 | "content-hash": "123456789", 1865 | "files": {"foo": [], "bar": [], "baz": []}, 1866 | }, 1867 | } 1868 | fix_lock_data(lock_data) 1869 | if lock_version == "2.1": 1870 | lock_data["package"][2]["groups"] = ["dev"] 1871 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1872 | set_package_requires(poetry, dev={"baz"}) 1873 | 1874 | exporter = Exporter(poetry, NullIO()) 1875 | if dev: 1876 | exporter.only_groups([MAIN_GROUP, "dev"]) 1877 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1878 | 1879 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1880 | content = f.read() 1881 | 1882 | assert content == "\n".join(expected) + "\n" 1883 | 1884 | 1885 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1886 | def test_exporter_exports_requirements_txt_with_legacy_packages_and_duplicate_sources( 1887 | tmp_path: Path, poetry: Poetry, lock_version: str 1888 | ) -> None: 1889 | poetry.pool.add_repository( 1890 | LegacyRepository( 1891 | "custom-example", 1892 | "https://example.com/simple", 1893 | ) 1894 | ) 1895 | poetry.pool.add_repository( 1896 | LegacyRepository( 1897 | "custom-foobaz", 1898 | "https://foobaz.com/simple", 1899 | ) 1900 | ) 1901 | lock_data: dict[str, Any] = { 1902 | "package": [ 1903 | { 1904 | "name": "foo", 1905 | "version": "1.2.3", 1906 | "optional": False, 1907 | "python-versions": "*", 1908 | "source": { 1909 | "type": "legacy", 1910 | "url": "https://example.com/simple", 1911 | "reference": "", 1912 | }, 1913 | }, 1914 | { 1915 | "name": "bar", 1916 | "version": "4.5.6", 1917 | "optional": False, 1918 | "python-versions": "*", 1919 | "source": { 1920 | "type": "legacy", 1921 | "url": "https://example.com/simple", 1922 | "reference": "", 1923 | }, 1924 | }, 1925 | { 1926 | "name": "baz", 1927 | "version": "7.8.9", 1928 | "optional": False, 1929 | "python-versions": "*", 1930 | "source": { 1931 | "type": "legacy", 1932 | "url": "https://foobaz.com/simple", 1933 | "reference": "", 1934 | }, 1935 | }, 1936 | ], 1937 | "metadata": { 1938 | "lock-version": lock_version, 1939 | "python-versions": "*", 1940 | "content-hash": "123456789", 1941 | "files": { 1942 | "foo": [{"name": "foo.whl", "hash": "12345"}], 1943 | "bar": [{"name": "bar.whl", "hash": "67890"}], 1944 | "baz": [{"name": "baz.whl", "hash": "24680"}], 1945 | }, 1946 | }, 1947 | } 1948 | fix_lock_data(lock_data) 1949 | if lock_version == "2.1": 1950 | lock_data["package"][1]["groups"] = ["dev"] 1951 | lock_data["package"][2]["groups"] = ["dev"] 1952 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1953 | set_package_requires(poetry, dev={"bar", "baz"}) 1954 | 1955 | exporter = Exporter(poetry, NullIO()) 1956 | exporter.only_groups([MAIN_GROUP, "dev"]) 1957 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1958 | 1959 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1960 | content = f.read() 1961 | 1962 | expected = f"""\ 1963 | --extra-index-url https://example.com/simple 1964 | --extra-index-url https://foobaz.com/simple 1965 | 1966 | bar==4.5.6 ; {MARKER_PY} \\ 1967 | --hash=sha256:67890 1968 | baz==7.8.9 ; {MARKER_PY} \\ 1969 | --hash=sha256:24680 1970 | foo==1.2.3 ; {MARKER_PY} \\ 1971 | --hash=sha256:12345 1972 | """ 1973 | 1974 | assert content == expected 1975 | 1976 | 1977 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1978 | def test_exporter_exports_requirements_txt_with_two_primary_sources( 1979 | tmp_path: Path, poetry: Poetry, lock_version: str 1980 | ) -> None: 1981 | poetry.pool.remove_repository("PyPI") 1982 | poetry.config.merge( 1983 | { 1984 | "repositories": { 1985 | "custom-a": {"url": "https://a.example.com/simple"}, 1986 | "custom-b": {"url": "https://b.example.com/simple"}, 1987 | }, 1988 | "http-basic": { 1989 | "custom-a": {"username": "foo", "password": "bar"}, 1990 | "custom-b": {"username": "baz", "password": "qux"}, 1991 | }, 1992 | } 1993 | ) 1994 | poetry.pool.add_repository( 1995 | LegacyRepository( 1996 | "custom-b", 1997 | "https://b.example.com/simple", 1998 | config=poetry.config, 1999 | ), 2000 | ) 2001 | poetry.pool.add_repository( 2002 | LegacyRepository( 2003 | "custom-a", 2004 | "https://a.example.com/simple", 2005 | config=poetry.config, 2006 | ), 2007 | ) 2008 | lock_data: dict[str, Any] = { 2009 | "package": [ 2010 | { 2011 | "name": "foo", 2012 | "version": "1.2.3", 2013 | "optional": False, 2014 | "python-versions": "*", 2015 | "source": { 2016 | "type": "legacy", 2017 | "url": "https://a.example.com/simple", 2018 | "reference": "", 2019 | }, 2020 | }, 2021 | { 2022 | "name": "bar", 2023 | "version": "4.5.6", 2024 | "optional": False, 2025 | "python-versions": "*", 2026 | "source": { 2027 | "type": "legacy", 2028 | "url": "https://b.example.com/simple", 2029 | "reference": "", 2030 | }, 2031 | }, 2032 | { 2033 | "name": "baz", 2034 | "version": "7.8.9", 2035 | "optional": False, 2036 | "python-versions": "*", 2037 | "source": { 2038 | "type": "legacy", 2039 | "url": "https://b.example.com/simple", 2040 | "reference": "", 2041 | }, 2042 | }, 2043 | ], 2044 | "metadata": { 2045 | "lock-version": lock_version, 2046 | "python-versions": "*", 2047 | "content-hash": "123456789", 2048 | "files": { 2049 | "foo": [{"name": "foo.whl", "hash": "12345"}], 2050 | "bar": [{"name": "bar.whl", "hash": "67890"}], 2051 | "baz": [{"name": "baz.whl", "hash": "24680"}], 2052 | }, 2053 | }, 2054 | } 2055 | fix_lock_data(lock_data) 2056 | if lock_version == "2.1": 2057 | lock_data["package"][1]["groups"] = ["dev"] 2058 | lock_data["package"][2]["groups"] = ["dev"] 2059 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2060 | set_package_requires(poetry, dev={"bar", "baz"}) 2061 | 2062 | exporter = Exporter(poetry, NullIO()) 2063 | exporter.only_groups([MAIN_GROUP, "dev"]) 2064 | exporter.with_credentials() 2065 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 2066 | 2067 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 2068 | content = f.read() 2069 | 2070 | expected = f"""\ 2071 | --index-url https://baz:qux@b.example.com/simple 2072 | --extra-index-url https://foo:bar@a.example.com/simple 2073 | 2074 | bar==4.5.6 ; {MARKER_PY} \\ 2075 | --hash=sha256:67890 2076 | baz==7.8.9 ; {MARKER_PY} \\ 2077 | --hash=sha256:24680 2078 | foo==1.2.3 ; {MARKER_PY} \\ 2079 | --hash=sha256:12345 2080 | """ 2081 | 2082 | assert content == expected 2083 | 2084 | 2085 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2086 | def test_exporter_exports_requirements_txt_with_legacy_packages_and_credentials( 2087 | tmp_path: Path, poetry: Poetry, config: Config, lock_version: str 2088 | ) -> None: 2089 | poetry.config.merge( 2090 | { 2091 | "repositories": {"custom": {"url": "https://example.com/simple"}}, 2092 | "http-basic": {"custom": {"username": "foo", "password": "bar"}}, 2093 | } 2094 | ) 2095 | poetry.pool.add_repository( 2096 | LegacyRepository("custom", "https://example.com/simple", config=poetry.config) 2097 | ) 2098 | lock_data: dict[str, Any] = { 2099 | "package": [ 2100 | { 2101 | "name": "foo", 2102 | "version": "1.2.3", 2103 | "optional": False, 2104 | "python-versions": "*", 2105 | }, 2106 | { 2107 | "name": "bar", 2108 | "version": "4.5.6", 2109 | "optional": False, 2110 | "python-versions": "*", 2111 | "source": { 2112 | "type": "legacy", 2113 | "url": "https://example.com/simple", 2114 | "reference": "", 2115 | }, 2116 | }, 2117 | ], 2118 | "metadata": { 2119 | "lock-version": lock_version, 2120 | "python-versions": "*", 2121 | "content-hash": "123456789", 2122 | "files": { 2123 | "foo": [{"name": "foo.whl", "hash": "12345"}], 2124 | "bar": [{"name": "bar.whl", "hash": "67890"}], 2125 | }, 2126 | }, 2127 | } 2128 | fix_lock_data(lock_data) 2129 | if lock_version == "2.1": 2130 | lock_data["package"][1]["groups"] = ["dev"] 2131 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2132 | set_package_requires(poetry, dev={"bar"}) 2133 | 2134 | exporter = Exporter(poetry, NullIO()) 2135 | exporter.only_groups([MAIN_GROUP, "dev"]) 2136 | exporter.with_credentials() 2137 | exporter.export( 2138 | "requirements.txt", 2139 | tmp_path, 2140 | "requirements.txt", 2141 | ) 2142 | 2143 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 2144 | content = f.read() 2145 | 2146 | expected = f"""\ 2147 | --extra-index-url https://foo:bar@example.com/simple 2148 | 2149 | bar==4.5.6 ; {MARKER_PY} \\ 2150 | --hash=sha256:67890 2151 | foo==1.2.3 ; {MARKER_PY} \\ 2152 | --hash=sha256:12345 2153 | """ 2154 | 2155 | assert content == expected 2156 | 2157 | 2158 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2159 | def test_exporter_exports_requirements_txt_to_standard_output( 2160 | tmp_path: Path, poetry: Poetry, lock_version: str 2161 | ) -> None: 2162 | lock_data = { 2163 | "package": [ 2164 | { 2165 | "name": "foo", 2166 | "version": "1.2.3", 2167 | "optional": False, 2168 | "python-versions": "*", 2169 | }, 2170 | { 2171 | "name": "bar", 2172 | "version": "4.5.6", 2173 | "optional": False, 2174 | "python-versions": "*", 2175 | }, 2176 | ], 2177 | "metadata": { 2178 | "lock-version": lock_version, 2179 | "python-versions": "*", 2180 | "content-hash": "123456789", 2181 | "files": {"foo": [], "bar": []}, 2182 | }, 2183 | } 2184 | fix_lock_data(lock_data) 2185 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2186 | set_package_requires(poetry) 2187 | 2188 | exporter = Exporter(poetry, NullIO()) 2189 | io = BufferedIO() 2190 | exporter.export("requirements.txt", tmp_path, io) 2191 | 2192 | expected = f"""\ 2193 | bar==4.5.6 ; {MARKER_PY} 2194 | foo==1.2.3 ; {MARKER_PY} 2195 | """ 2196 | 2197 | assert io.fetch_output() == expected 2198 | 2199 | 2200 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2201 | def test_exporter_doesnt_confuse_repeated_packages( 2202 | tmp_path: Path, poetry: Poetry, lock_version: str 2203 | ) -> None: 2204 | # Testcase derived from . 2205 | lock_data: dict[str, Any] = { 2206 | "package": [ 2207 | { 2208 | "name": "celery", 2209 | "version": "5.1.2", 2210 | "optional": False, 2211 | "python-versions": "<3.7", 2212 | "dependencies": { 2213 | "click": ">=7.0,<8.0", 2214 | "click-didyoumean": ">=0.0.3", 2215 | "click-plugins": ">=1.1.1", 2216 | }, 2217 | }, 2218 | { 2219 | "name": "celery", 2220 | "version": "5.2.3", 2221 | "optional": False, 2222 | "python-versions": ">=3.7", 2223 | "dependencies": { 2224 | "click": ">=8.0.3,<9.0", 2225 | "click-didyoumean": ">=0.0.3", 2226 | "click-plugins": ">=1.1.1", 2227 | }, 2228 | }, 2229 | { 2230 | "name": "click", 2231 | "version": "7.1.2", 2232 | "optional": False, 2233 | "python-versions": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", 2234 | }, 2235 | { 2236 | "name": "click", 2237 | "version": "8.0.3", 2238 | "optional": False, 2239 | "python-versions": ">=3.6", 2240 | "dependencies": {}, 2241 | }, 2242 | { 2243 | "name": "click-didyoumean", 2244 | "version": "0.0.3", 2245 | "optional": False, 2246 | "python-versions": "*", 2247 | "dependencies": {"click": "*"}, 2248 | }, 2249 | { 2250 | "name": "click-didyoumean", 2251 | "version": "0.3.0", 2252 | "optional": False, 2253 | "python-versions": ">=3.6.2,<4.0.0", 2254 | "dependencies": {"click": ">=7"}, 2255 | }, 2256 | { 2257 | "name": "click-plugins", 2258 | "version": "1.1.1", 2259 | "optional": False, 2260 | "python-versions": "*", 2261 | "dependencies": {"click": ">=4.0"}, 2262 | }, 2263 | ], 2264 | "metadata": { 2265 | "lock-version": lock_version, 2266 | "python-versions": "^3.6", 2267 | "content-hash": ( 2268 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 2269 | ), 2270 | "files": { 2271 | "celery": [], 2272 | "click-didyoumean": [], 2273 | "click-plugins": [], 2274 | "click": [], 2275 | }, 2276 | }, 2277 | } 2278 | fix_lock_data(lock_data) 2279 | if lock_version == "2.1": 2280 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 2281 | lock_data["package"][1]["markers"] = "python_version >= '3.7'" 2282 | lock_data["package"][2]["markers"] = "python_version < '3.7'" 2283 | lock_data["package"][3]["markers"] = "python_version >= '3.7'" 2284 | lock_data["package"][4]["markers"] = "python_full_version < '3.6.2'" 2285 | lock_data["package"][5]["markers"] = "python_full_version >= '3.6.2'" 2286 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2287 | root = poetry.package.with_dependency_groups([], only=True) 2288 | root.python_versions = "^3.6" 2289 | root.add_dependency( 2290 | Factory.create_dependency( 2291 | name="celery", constraint={"version": "5.1.2", "python": "<3.7"} 2292 | ) 2293 | ) 2294 | root.add_dependency( 2295 | Factory.create_dependency( 2296 | name="celery", constraint={"version": "5.2.3", "python": ">=3.7"} 2297 | ) 2298 | ) 2299 | poetry._package = root 2300 | 2301 | exporter = Exporter(poetry, NullIO()) 2302 | exporter.only_groups([MAIN_GROUP, "dev"]) 2303 | io = BufferedIO() 2304 | exporter.export("requirements.txt", tmp_path, io) 2305 | 2306 | expected = f"""\ 2307 | celery==5.1.2 ; {MARKER_PY36_ONLY} 2308 | celery==5.2.3 ; {MARKER_PY37} 2309 | click-didyoumean==0.0.3 ; {MARKER_PY36_PY362 if lock_version == "2.1" else MARKER_PY36_PY362_ALT} 2310 | click-didyoumean==0.3.0 ; {MARKER_PY362_PY40} 2311 | click-plugins==1.1.1 ; {MARKER_PY36} 2312 | click==7.1.2 ; {MARKER_PY36_ONLY} 2313 | click==8.0.3 ; {MARKER_PY37} 2314 | """ 2315 | 2316 | assert io.fetch_output() == expected 2317 | 2318 | 2319 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2320 | def test_exporter_handles_extras_next_to_non_extras( 2321 | tmp_path: Path, poetry: Poetry, lock_version: str 2322 | ) -> None: 2323 | # Testcase similar to the solver testcase added at #5305. 2324 | lock_data = { 2325 | "package": [ 2326 | { 2327 | "name": "localstack", 2328 | "python-versions": "*", 2329 | "version": "1.0.0", 2330 | "optional": False, 2331 | "dependencies": { 2332 | "localstack-ext": [ 2333 | {"version": ">=1.0.0"}, 2334 | { 2335 | "version": ">=1.0.0", 2336 | "extras": ["bar"], 2337 | "markers": 'extra == "foo"', 2338 | }, 2339 | ] 2340 | }, 2341 | "extras": {"foo": ["localstack-ext[bar] (>=1.0.0)"]}, 2342 | }, 2343 | { 2344 | "name": "localstack-ext", 2345 | "python-versions": "*", 2346 | "version": "1.0.0", 2347 | "optional": False, 2348 | "dependencies": { 2349 | "something": "*", 2350 | "something-else": { 2351 | "version": ">=1.0.0", 2352 | "markers": 'extra == "bar"', 2353 | }, 2354 | "another-thing": { 2355 | "version": ">=1.0.0", 2356 | "markers": 'extra == "baz"', 2357 | }, 2358 | }, 2359 | "extras": { 2360 | "bar": ["something-else (>=1.0.0)"], 2361 | "baz": ["another-thing (>=1.0.0)"], 2362 | }, 2363 | }, 2364 | { 2365 | "name": "something", 2366 | "python-versions": "*", 2367 | "version": "1.0.0", 2368 | "optional": False, 2369 | "dependencies": {}, 2370 | }, 2371 | { 2372 | "name": "something-else", 2373 | "python-versions": "*", 2374 | "version": "1.0.0", 2375 | "optional": False, 2376 | "dependencies": {}, 2377 | }, 2378 | ], 2379 | "metadata": { 2380 | "lock-version": lock_version, 2381 | "python-versions": "^3.6", 2382 | "content-hash": ( 2383 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 2384 | ), 2385 | "files": { 2386 | "localstack": [], 2387 | "localstack-ext": [], 2388 | "something": [], 2389 | "something-else": [], 2390 | "another-thing": [], 2391 | }, 2392 | }, 2393 | } 2394 | fix_lock_data(lock_data) 2395 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2396 | root = poetry.package.with_dependency_groups([], only=True) 2397 | root.python_versions = "^3.6" 2398 | root.add_dependency( 2399 | Factory.create_dependency( 2400 | name="localstack", constraint={"version": "^1.0.0", "extras": ["foo"]} 2401 | ) 2402 | ) 2403 | poetry._package = root 2404 | 2405 | exporter = Exporter(poetry, NullIO()) 2406 | io = BufferedIO() 2407 | exporter.export("requirements.txt", tmp_path, io) 2408 | 2409 | # It does not matter whether packages are exported with extras or not 2410 | # because all dependencies are listed explicitly. 2411 | if lock_version == "1.1": 2412 | expected = f"""\ 2413 | localstack-ext==1.0.0 ; {MARKER_PY36} 2414 | localstack-ext[bar]==1.0.0 ; {MARKER_PY36} 2415 | localstack[foo]==1.0.0 ; {MARKER_PY36} 2416 | something-else==1.0.0 ; {MARKER_PY36} 2417 | something==1.0.0 ; {MARKER_PY36} 2418 | """ 2419 | else: 2420 | expected = f"""\ 2421 | localstack-ext==1.0.0 ; {MARKER_PY36} 2422 | localstack==1.0.0 ; {MARKER_PY36} 2423 | something-else==1.0.0 ; {MARKER_PY36} 2424 | something==1.0.0 ; {MARKER_PY36} 2425 | """ 2426 | 2427 | assert io.fetch_output() == expected 2428 | 2429 | 2430 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2431 | def test_exporter_handles_overlapping_python_versions( 2432 | tmp_path: Path, poetry: Poetry, lock_version: str 2433 | ) -> None: 2434 | # Testcase derived from 2435 | # https://github.com/python-poetry/poetry-plugin-export/issues/32. 2436 | lock_data: dict[str, Any] = { 2437 | "package": [ 2438 | { 2439 | "name": "ipython", 2440 | "python-versions": ">=3.6", 2441 | "version": "7.16.3", 2442 | "optional": False, 2443 | "dependencies": {}, 2444 | }, 2445 | { 2446 | "name": "ipython", 2447 | "python-versions": ">=3.7", 2448 | "version": "7.34.0", 2449 | "optional": False, 2450 | "dependencies": {}, 2451 | }, 2452 | { 2453 | "name": "slash", 2454 | "python-versions": ">=3.6.*", 2455 | "version": "1.13.0", 2456 | "optional": False, 2457 | "dependencies": { 2458 | "ipython": [ 2459 | { 2460 | "version": "*", 2461 | "markers": ( 2462 | 'python_version >= "3.6" and implementation_name !=' 2463 | ' "pypy"' 2464 | ), 2465 | }, 2466 | { 2467 | "version": "<7.17.0", 2468 | "markers": ( 2469 | 'python_version < "3.6" and implementation_name !=' 2470 | ' "pypy"' 2471 | ), 2472 | }, 2473 | ], 2474 | }, 2475 | }, 2476 | ], 2477 | "metadata": { 2478 | "lock-version": lock_version, 2479 | "python-versions": "^3.6", 2480 | "content-hash": ( 2481 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 2482 | ), 2483 | "files": { 2484 | "ipython": [], 2485 | "slash": [], 2486 | }, 2487 | }, 2488 | } 2489 | fix_lock_data(lock_data) 2490 | if lock_version == "2.1": 2491 | lock_data["package"][0]["markers"] = ( 2492 | "python_version >= '3.6' and python_version < '3.7'" 2493 | ) 2494 | lock_data["package"][1]["markers"] = "python_version >= '3.7'" 2495 | lock_data["package"][2]["markers"] = "implementation_name == 'cpython'" 2496 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2497 | root = poetry.package.with_dependency_groups([], only=True) 2498 | root.python_versions = "^3.6" 2499 | root.add_dependency( 2500 | Factory.create_dependency( 2501 | name="ipython", 2502 | constraint={"version": "*", "python": "~3.6"}, 2503 | ) 2504 | ) 2505 | root.add_dependency( 2506 | Factory.create_dependency( 2507 | name="ipython", 2508 | constraint={"version": "^7.17", "python": "^3.7"}, 2509 | ) 2510 | ) 2511 | root.add_dependency( 2512 | Factory.create_dependency( 2513 | name="slash", 2514 | constraint={ 2515 | "version": "^1.12", 2516 | "markers": "implementation_name == 'cpython'", 2517 | }, 2518 | ) 2519 | ) 2520 | poetry._package = root 2521 | 2522 | exporter = Exporter(poetry, NullIO()) 2523 | io = BufferedIO() 2524 | exporter.export("requirements.txt", tmp_path, io) 2525 | 2526 | expected = f"""\ 2527 | ipython==7.16.3 ; {MARKER_PY36_ONLY} 2528 | ipython==7.34.0 ; {MARKER_PY37} 2529 | slash==1.13.0 ; {MARKER_PY36} and {MARKER_CPYTHON} 2530 | """ 2531 | 2532 | assert io.fetch_output() == expected 2533 | 2534 | 2535 | @pytest.mark.parametrize( 2536 | ["with_extras", "expected"], 2537 | [ 2538 | ( 2539 | True, 2540 | [f"foo[test]==1.0.0 ; {MARKER_PY36}", f"pytest==6.24.0 ; {MARKER_PY36}"], 2541 | ), 2542 | ( 2543 | False, 2544 | [f"foo==1.0.0 ; {MARKER_PY36}"], 2545 | ), 2546 | ], 2547 | ) 2548 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2549 | def test_exporter_omits_unwanted_extras( 2550 | tmp_path: Path, 2551 | poetry: Poetry, 2552 | with_extras: bool, 2553 | expected: list[str], 2554 | lock_version: str, 2555 | ) -> None: 2556 | # Testcase derived from 2557 | # https://github.com/python-poetry/poetry/issues/5779 2558 | lock_data: dict[str, Any] = { 2559 | "package": [ 2560 | { 2561 | "name": "foo", 2562 | "python-versions": ">=3.6", 2563 | "version": "1.0.0", 2564 | "optional": False, 2565 | "dependencies": {"pytest": {"version": "^6.2.4", "optional": True}}, 2566 | "extras": {"test": ["pytest (>=6.2.4,<7.0.0)"]}, 2567 | }, 2568 | { 2569 | "name": "pytest", 2570 | "python-versions": ">=3.6", 2571 | "version": "6.24.0", 2572 | "optional": False, 2573 | "dependencies": {}, 2574 | }, 2575 | ], 2576 | "metadata": { 2577 | "lock-version": lock_version, 2578 | "python-versions": "^3.6", 2579 | "content-hash": ( 2580 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 2581 | ), 2582 | "files": { 2583 | "foo": [], 2584 | "pytest": [], 2585 | }, 2586 | }, 2587 | } 2588 | fix_lock_data(lock_data) 2589 | if lock_version == "2.1": 2590 | lock_data["package"][0]["groups"] = ["main", "with-extras"] 2591 | lock_data["package"][1]["groups"] = ["with-extras"] 2592 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2593 | root = poetry.package.with_dependency_groups([], only=True) 2594 | root.python_versions = "^3.6" 2595 | root.add_dependency( 2596 | Factory.create_dependency( 2597 | name="foo", 2598 | constraint={"version": "*"}, 2599 | ) 2600 | ) 2601 | root.add_dependency( 2602 | Factory.create_dependency( 2603 | name="foo", 2604 | constraint={"version": "*", "extras": ["test"]}, 2605 | groups=["with-extras"], 2606 | ) 2607 | ) 2608 | poetry._package = root 2609 | 2610 | io = BufferedIO() 2611 | exporter = Exporter(poetry, NullIO()) 2612 | if with_extras: 2613 | exporter.only_groups(["with-extras"]) 2614 | # It does not matter whether packages are exported with extras or not 2615 | # because all dependencies are listed explicitly. 2616 | if lock_version == "2.1": 2617 | expected = [req.replace("foo[test]", "foo") for req in expected] 2618 | exporter.export("requirements.txt", tmp_path, io) 2619 | 2620 | assert io.fetch_output() == "\n".join(expected) + "\n" 2621 | 2622 | 2623 | @pytest.mark.parametrize( 2624 | ["fmt", "expected"], 2625 | [ 2626 | ( 2627 | "constraints.txt", 2628 | [ 2629 | f"bar==4.5.6 ; {MARKER_PY}", 2630 | f"baz==7.8.9 ; {MARKER_PY}", 2631 | f"foo==1.2.3 ; {MARKER_PY}", 2632 | ], 2633 | ), 2634 | ( 2635 | "requirements.txt", 2636 | [ 2637 | f"bar==4.5.6 ; {MARKER_PY}", 2638 | f"bar[baz]==4.5.6 ; {MARKER_PY}", 2639 | f"baz==7.8.9 ; {MARKER_PY}", 2640 | f"foo==1.2.3 ; {MARKER_PY}", 2641 | ], 2642 | ), 2643 | ], 2644 | ) 2645 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2646 | def test_exporter_omits_and_includes_extras_for_txt_formats( 2647 | tmp_path: Path, poetry: Poetry, fmt: str, expected: list[str], lock_version: str 2648 | ) -> None: 2649 | lock_data = { 2650 | "package": [ 2651 | { 2652 | "name": "foo", 2653 | "version": "1.2.3", 2654 | "optional": False, 2655 | "python-versions": "*", 2656 | "dependencies": { 2657 | "bar": { 2658 | "extras": ["baz"], 2659 | "version": ">=0.1.0", 2660 | } 2661 | }, 2662 | }, 2663 | { 2664 | "name": "bar", 2665 | "version": "4.5.6", 2666 | "optional": False, 2667 | "python-versions": "*", 2668 | "dependencies": { 2669 | "baz": { 2670 | "version": ">=0.1.0", 2671 | "optional": True, 2672 | "markers": "extra == 'baz'", 2673 | } 2674 | }, 2675 | "extras": {"baz": ["baz (>=0.1.0)"]}, 2676 | }, 2677 | { 2678 | "name": "baz", 2679 | "version": "7.8.9", 2680 | "optional": False, 2681 | "python-versions": "*", 2682 | }, 2683 | ], 2684 | "metadata": { 2685 | "lock-version": lock_version, 2686 | "python-versions": "*", 2687 | "content-hash": "123456789", 2688 | "files": {"foo": [], "bar": [], "baz": []}, 2689 | }, 2690 | } 2691 | fix_lock_data(lock_data) 2692 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2693 | set_package_requires(poetry) 2694 | 2695 | exporter = Exporter(poetry, NullIO()) 2696 | exporter.export(fmt, tmp_path, "exported.txt") 2697 | 2698 | with (tmp_path / "exported.txt").open(encoding="utf-8") as f: 2699 | content = f.read() 2700 | 2701 | # It does not matter whether packages are exported with extras or not 2702 | # because all dependencies are listed explicitly. 2703 | if lock_version == "2.1": 2704 | expected = [req for req in expected if not req.startswith("bar[baz]")] 2705 | assert content == "\n".join(expected) + "\n" 2706 | 2707 | 2708 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2709 | def test_exporter_prints_warning_for_constraints_txt_with_editable_packages( 2710 | tmp_path: Path, poetry: Poetry, lock_version: str 2711 | ) -> None: 2712 | lock_data = { 2713 | "package": [ 2714 | { 2715 | "name": "foo", 2716 | "version": "1.2.3", 2717 | "optional": False, 2718 | "python-versions": "*", 2719 | "source": { 2720 | "type": "git", 2721 | "url": "https://github.com/foo/foo.git", 2722 | "reference": "123456", 2723 | }, 2724 | "develop": True, 2725 | }, 2726 | { 2727 | "name": "bar", 2728 | "version": "7.8.9", 2729 | "optional": False, 2730 | "python-versions": "*", 2731 | }, 2732 | { 2733 | "name": "baz", 2734 | "version": "4.5.6", 2735 | "optional": False, 2736 | "python-versions": "*", 2737 | "source": { 2738 | "type": "directory", 2739 | "url": "sample_project", 2740 | "reference": "", 2741 | }, 2742 | "develop": True, 2743 | }, 2744 | ], 2745 | "metadata": { 2746 | "lock-version": lock_version, 2747 | "python-versions": "*", 2748 | "content-hash": "123456789", 2749 | "files": {"foo": [], "bar": [], "baz": []}, 2750 | }, 2751 | } 2752 | fix_lock_data(lock_data) 2753 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2754 | set_package_requires(poetry) 2755 | 2756 | io = BufferedIO() 2757 | exporter = Exporter(poetry, io) 2758 | exporter.export("constraints.txt", tmp_path, "constraints.txt") 2759 | 2760 | expected_error_out = ( 2761 | "Warning: foo is locked in develop (editable) mode, which is " 2762 | "incompatible with the constraints.txt format.\n" 2763 | "Warning: baz is locked in develop (editable) mode, which is " 2764 | "incompatible with the constraints.txt format.\n" 2765 | ) 2766 | 2767 | assert io.fetch_error() == expected_error_out 2768 | 2769 | with (tmp_path / "constraints.txt").open(encoding="utf-8") as f: 2770 | content = f.read() 2771 | 2772 | assert content == f"bar==7.8.9 ; {MARKER_PY}\n" 2773 | 2774 | 2775 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2776 | def test_exporter_respects_package_sources( 2777 | tmp_path: Path, poetry: Poetry, lock_version: str 2778 | ) -> None: 2779 | lock_data: dict[str, Any] = { 2780 | "package": [ 2781 | { 2782 | "name": "foo", 2783 | "python-versions": ">=3.6", 2784 | "version": "1.0.0", 2785 | "optional": False, 2786 | "dependencies": {}, 2787 | "source": { 2788 | "type": "url", 2789 | "url": "https://example.com/foo-darwin.whl", 2790 | }, 2791 | }, 2792 | { 2793 | "name": "foo", 2794 | "python-versions": ">=3.6", 2795 | "version": "1.0.0", 2796 | "optional": False, 2797 | "dependencies": {}, 2798 | "source": { 2799 | "type": "url", 2800 | "url": "https://example.com/foo-linux.whl", 2801 | }, 2802 | }, 2803 | ], 2804 | "metadata": { 2805 | "lock-version": lock_version, 2806 | "python-versions": "^3.6", 2807 | "content-hash": ( 2808 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 2809 | ), 2810 | "files": { 2811 | "foo": [], 2812 | }, 2813 | }, 2814 | } 2815 | fix_lock_data(lock_data) 2816 | if lock_version == "2.1": 2817 | lock_data["package"][0]["markers"] = "sys_platform == 'darwin'" 2818 | lock_data["package"][1]["markers"] = "sys_platform == 'linux'" 2819 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2820 | root = poetry.package.with_dependency_groups([], only=True) 2821 | root.python_versions = "^3.6" 2822 | root.add_dependency( 2823 | Factory.create_dependency( 2824 | name="foo", 2825 | constraint={ 2826 | "url": "https://example.com/foo-linux.whl", 2827 | "platform": "linux", 2828 | }, 2829 | ) 2830 | ) 2831 | root.add_dependency( 2832 | Factory.create_dependency( 2833 | name="foo", 2834 | constraint={ 2835 | "url": "https://example.com/foo-darwin.whl", 2836 | "platform": "darwin", 2837 | }, 2838 | ) 2839 | ) 2840 | poetry._package = root 2841 | 2842 | io = BufferedIO() 2843 | exporter = Exporter(poetry, NullIO()) 2844 | exporter.export("requirements.txt", tmp_path, io) 2845 | 2846 | expected = f"""\ 2847 | foo @ https://example.com/foo-darwin.whl ; {MARKER_PY36} and {MARKER_DARWIN} 2848 | foo @ https://example.com/foo-linux.whl ; {MARKER_PY36} and {MARKER_LINUX} 2849 | """ 2850 | 2851 | assert io.fetch_output() == expected 2852 | 2853 | 2854 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2855 | def test_exporter_tolerates_non_existent_extra( 2856 | tmp_path: Path, poetry: Poetry, lock_version: str 2857 | ) -> None: 2858 | # foo actually has a 'bar' extra, but pyproject.toml mistakenly references a 'baz' 2859 | # extra. 2860 | lock_data = { 2861 | "package": [ 2862 | { 2863 | "name": "foo", 2864 | "version": "1.2.3", 2865 | "optional": False, 2866 | "python-versions": "*", 2867 | "dependencies": { 2868 | "bar": { 2869 | "version": ">=0.1.0", 2870 | "optional": True, 2871 | "markers": "extra == 'bar'", 2872 | } 2873 | }, 2874 | "extras": {"bar": ["bar (>=0.1.0)"]}, 2875 | }, 2876 | ], 2877 | "metadata": { 2878 | "lock-version": lock_version, 2879 | "python-versions": "*", 2880 | "content-hash": "123456789", 2881 | "files": {"foo": [], "bar": []}, 2882 | }, 2883 | } 2884 | fix_lock_data(lock_data) 2885 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2886 | root = poetry.package.with_dependency_groups([], only=True) 2887 | root.add_dependency( 2888 | Factory.create_dependency( 2889 | name="foo", constraint={"version": "^1.2", "extras": ["baz"]} 2890 | ) 2891 | ) 2892 | poetry._package = root 2893 | 2894 | exporter = Exporter(poetry, NullIO()) 2895 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 2896 | 2897 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 2898 | content = f.read() 2899 | 2900 | if lock_version == "1.1": 2901 | expected = f"""\ 2902 | foo[baz]==1.2.3 ; {MARKER_PY27} or {MARKER_PY36} 2903 | """ 2904 | else: 2905 | expected = f"""\ 2906 | foo==1.2.3 ; {MARKER_PY27} or {MARKER_PY36} 2907 | """ 2908 | assert content == expected 2909 | 2910 | 2911 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2912 | def test_exporter_exports_extra_index_url_and_trusted_host( 2913 | tmp_path: Path, poetry: Poetry, lock_version: str 2914 | ) -> None: 2915 | poetry.pool.add_repository( 2916 | LegacyRepository( 2917 | "custom", 2918 | "http://example.com/simple", 2919 | ), 2920 | priority=Priority.EXPLICIT, 2921 | ) 2922 | lock_data = { 2923 | "package": [ 2924 | { 2925 | "name": "foo", 2926 | "version": "1.2.3", 2927 | "optional": False, 2928 | "python-versions": "*", 2929 | "dependencies": {"bar": "*"}, 2930 | }, 2931 | { 2932 | "name": "bar", 2933 | "version": "4.5.6", 2934 | "optional": False, 2935 | "python-versions": "*", 2936 | "source": { 2937 | "type": "legacy", 2938 | "url": "http://example.com/simple", 2939 | "reference": "", 2940 | }, 2941 | }, 2942 | ], 2943 | "metadata": { 2944 | "lock-version": lock_version, 2945 | "python-versions": "*", 2946 | "content-hash": "123456789", 2947 | "files": {"foo": [], "bar": []}, 2948 | }, 2949 | } 2950 | fix_lock_data(lock_data) 2951 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2952 | set_package_requires(poetry) 2953 | 2954 | exporter = Exporter(poetry, NullIO()) 2955 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 2956 | 2957 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 2958 | content = f.read() 2959 | 2960 | expected = f"""\ 2961 | --trusted-host example.com 2962 | --extra-index-url http://example.com/simple 2963 | 2964 | bar==4.5.6 ; {MARKER_PY} 2965 | foo==1.2.3 ; {MARKER_PY} 2966 | """ 2967 | assert content == expected 2968 | 2969 | 2970 | @pytest.mark.parametrize("lock_version", ("2.0", "2.1")) 2971 | def test_exporter_not_confused_by_extras_in_sub_dependencies( 2972 | tmp_path: Path, poetry: Poetry, lock_version: str 2973 | ) -> None: 2974 | # Testcase derived from 2975 | # https://github.com/python-poetry/poetry-plugin-export/issues/208 2976 | lock_data: dict[str, Any] = { 2977 | "package": [ 2978 | { 2979 | "name": "typer", 2980 | "python-versions": ">=3.6", 2981 | "version": "0.9.0", 2982 | "optional": False, 2983 | "files": [], 2984 | "dependencies": { 2985 | "click": ">=7.1.1,<9.0.0", 2986 | "colorama": { 2987 | "version": ">=0.4.3,<0.5.0", 2988 | "optional": True, 2989 | "markers": 'extra == "all"', 2990 | }, 2991 | }, 2992 | "extras": {"all": ["colorama (>=0.4.3,<0.5.0)"]}, 2993 | }, 2994 | { 2995 | "name": "click", 2996 | "python-versions": ">=3.7", 2997 | "version": "8.1.3", 2998 | "optional": False, 2999 | "files": [], 3000 | "dependencies": { 3001 | "colorama": { 3002 | "version": "*", 3003 | "markers": 'platform_system == "Windows"', 3004 | } 3005 | }, 3006 | }, 3007 | { 3008 | "name": "colorama", 3009 | "python-versions": ">=3.7", 3010 | "version": "0.4.6", 3011 | "optional": False, 3012 | "files": [], 3013 | }, 3014 | ], 3015 | "metadata": { 3016 | "lock-version": lock_version, 3017 | "python-versions": "^3.11", 3018 | "content-hash": ( 3019 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 3020 | ), 3021 | }, 3022 | } 3023 | if lock_version == "2.1": 3024 | for locked_package in lock_data["package"]: 3025 | locked_package["groups"] = ["main"] 3026 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 3027 | root = poetry.package.with_dependency_groups([], only=True) 3028 | root.python_versions = "^3.11" 3029 | root.add_dependency( 3030 | Factory.create_dependency( 3031 | name="typer", 3032 | constraint={"version": "^0.9.0", "extras": ["all"]}, 3033 | ) 3034 | ) 3035 | poetry._package = root 3036 | 3037 | io = BufferedIO() 3038 | exporter = Exporter(poetry, NullIO()) 3039 | exporter.export("requirements.txt", tmp_path, io) 3040 | 3041 | if lock_version == "2.0": 3042 | expected = """\ 3043 | click==8.1.3 ; python_version >= "3.11" and python_version < "4.0" 3044 | colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" 3045 | typer[all]==0.9.0 ; python_version >= "3.11" and python_version < "4.0" 3046 | """ 3047 | else: 3048 | expected = """\ 3049 | click==8.1.3 ; python_version >= "3.11" and python_version < "4.0" 3050 | colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" 3051 | typer==0.9.0 ; python_version >= "3.11" and python_version < "4.0" 3052 | """ 3053 | assert io.fetch_output() == expected 3054 | 3055 | 3056 | @pytest.mark.parametrize( 3057 | ("priorities", "expected"), 3058 | [ 3059 | ([("custom-a", Priority.PRIMARY), ("custom-b", Priority.PRIMARY)], ("a", "b")), 3060 | ([("custom-b", Priority.PRIMARY), ("custom-a", Priority.PRIMARY)], ("b", "a")), 3061 | ( 3062 | [("custom-b", Priority.SUPPLEMENTAL), ("custom-a", Priority.PRIMARY)], 3063 | ("a", "b"), 3064 | ), 3065 | ([("custom-b", Priority.EXPLICIT), ("custom-a", Priority.PRIMARY)], ("a", "b")), 3066 | ( 3067 | [ 3068 | ("PyPI", Priority.PRIMARY), 3069 | ("custom-a", Priority.PRIMARY), 3070 | ("custom-b", Priority.PRIMARY), 3071 | ], 3072 | ("", "a", "b"), 3073 | ), 3074 | ( 3075 | [ 3076 | ("PyPI", Priority.EXPLICIT), 3077 | ("custom-a", Priority.PRIMARY), 3078 | ("custom-b", Priority.PRIMARY), 3079 | ], 3080 | ("", "a", "b"), 3081 | ), 3082 | ( 3083 | [ 3084 | ("custom-a", Priority.PRIMARY), 3085 | ("custom-b", Priority.PRIMARY), 3086 | ("PyPI", Priority.SUPPLEMENTAL), 3087 | ], 3088 | ("", "a", "b"), 3089 | ), 3090 | ], 3091 | ) 3092 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 3093 | def test_exporter_index_urls( 3094 | tmp_path: Path, 3095 | poetry: Poetry, 3096 | priorities: list[tuple[str, Priority]], 3097 | expected: tuple[str, ...], 3098 | lock_version: str, 3099 | ) -> None: 3100 | pypi = poetry.pool.repository("PyPI") 3101 | poetry.pool.remove_repository("PyPI") 3102 | for name, prio in priorities: 3103 | if name.lower() == "pypi": 3104 | repo = pypi 3105 | else: 3106 | repo = LegacyRepository(name, f"https://{name[-1]}.example.com/simple") 3107 | poetry.pool.add_repository(repo, priority=prio) 3108 | 3109 | lock_data: dict[str, Any] = { 3110 | "package": [ 3111 | { 3112 | "name": "foo", 3113 | "version": "1.2.3", 3114 | "optional": False, 3115 | "python-versions": "*", 3116 | "source": { 3117 | "type": "legacy", 3118 | "url": "https://a.example.com/simple", 3119 | "reference": "", 3120 | }, 3121 | }, 3122 | { 3123 | "name": "bar", 3124 | "version": "4.5.6", 3125 | "optional": False, 3126 | "python-versions": "*", 3127 | "source": { 3128 | "type": "legacy", 3129 | "url": "https://b.example.com/simple", 3130 | "reference": "", 3131 | }, 3132 | }, 3133 | ], 3134 | "metadata": { 3135 | "lock-version": lock_version, 3136 | "python-versions": "*", 3137 | "content-hash": "123456789", 3138 | "files": { 3139 | "foo": [{"name": "foo.whl", "hash": "12345"}], 3140 | "bar": [{"name": "bar.whl", "hash": "67890"}], 3141 | }, 3142 | }, 3143 | } 3144 | fix_lock_data(lock_data) 3145 | if lock_version == "2.1": 3146 | lock_data["package"][0]["groups"] = ["dev"] 3147 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 3148 | set_package_requires(poetry, dev={"bar"}) 3149 | 3150 | exporter = Exporter(poetry, NullIO()) 3151 | exporter.only_groups([MAIN_GROUP, "dev"]) 3152 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 3153 | 3154 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 3155 | content = f.read() 3156 | 3157 | expected_urls = [ 3158 | f"--extra-index-url https://{name[-1]}.example.com/simple" 3159 | for name in expected[1:] 3160 | ] 3161 | if expected[0]: 3162 | expected_urls = [ 3163 | f"--index-url https://{expected[0]}.example.com/simple", 3164 | *expected_urls, 3165 | ] 3166 | url_string = "\n".join(expected_urls) 3167 | 3168 | expected_content = f"""\ 3169 | {url_string} 3170 | 3171 | bar==4.5.6 ; {MARKER_PY} \\ 3172 | --hash=sha256:67890 3173 | foo==1.2.3 ; {MARKER_PY} \\ 3174 | --hash=sha256:12345 3175 | """ 3176 | 3177 | assert content == expected_content 3178 | 3179 | 3180 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 3181 | def test_dependency_walk_error( 3182 | tmp_path: Path, poetry: Poetry, lock_version: str 3183 | ) -> None: 3184 | """ 3185 | With lock file version 2.1 we can export lock files 3186 | that resulted in a DependencyWalkerError with lower lock file versions. 3187 | 3188 | root 3189 | ├── foo >=0 ; python_version < "3.9" 3190 | ├── foo >=1 ; python_version >= "3.9" 3191 | ├── bar ==1 ; python_version < "3.9" 3192 | │ └── foo ==1 ; python_version < "3.9" 3193 | └── bar ==2 ; python_version >= "3.9" 3194 | └── foo ==2 ; python_version >= "3.9" 3195 | 3196 | Only considering the root dependency, foo 2 is a valid solution 3197 | for all environments. However, due to bar depending on foo, 3198 | foo 1 must be chosen for Python 3.8 and lower. 3199 | """ 3200 | lock_data: dict[str, Any] = { 3201 | "package": [ 3202 | { 3203 | "name": "foo", 3204 | "version": "1", 3205 | "optional": False, 3206 | "python-versions": "*", 3207 | }, 3208 | { 3209 | "name": "foo", 3210 | "version": "2", 3211 | "optional": False, 3212 | "python-versions": "*", 3213 | }, 3214 | { 3215 | "name": "bar", 3216 | "version": "1", 3217 | "optional": False, 3218 | "python-versions": "*", 3219 | "dependencies": {"foo": "1"}, 3220 | }, 3221 | { 3222 | "name": "bar", 3223 | "version": "2", 3224 | "optional": False, 3225 | "python-versions": "*", 3226 | "dependencies": {"foo": "2"}, 3227 | }, 3228 | ], 3229 | "metadata": { 3230 | "lock-version": lock_version, 3231 | "python-versions": "*", 3232 | "content-hash": "123456789", 3233 | "files": {"foo": [], "bar": []}, 3234 | }, 3235 | } 3236 | fix_lock_data(lock_data) 3237 | if lock_version == "2.1": 3238 | lock_data["package"][0]["markers"] = "python_version < '3.9'" 3239 | lock_data["package"][1]["markers"] = "python_version >= '3.9'" 3240 | lock_data["package"][2]["markers"] = "python_version < '3.9'" 3241 | lock_data["package"][3]["markers"] = "python_version >= '3.9'" 3242 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 3243 | poetry.package.python_versions = "^3.8" 3244 | poetry.package.add_dependency( 3245 | Factory.create_dependency( 3246 | name="foo", constraint={"version": ">=0", "python": "<3.9"} 3247 | ) 3248 | ) 3249 | poetry.package.add_dependency( 3250 | Factory.create_dependency( 3251 | name="foo", constraint={"version": ">=1", "python": ">=3.9"} 3252 | ) 3253 | ) 3254 | poetry.package.add_dependency( 3255 | Factory.create_dependency( 3256 | name="bar", constraint={"version": "1", "python": "<3.9"} 3257 | ) 3258 | ) 3259 | poetry.package.add_dependency( 3260 | Factory.create_dependency( 3261 | name="bar", constraint={"version": "2", "python": ">=3.9"} 3262 | ) 3263 | ) 3264 | 3265 | exporter = Exporter(poetry, NullIO()) 3266 | if lock_version == "1.1": 3267 | with pytest.raises(DependencyWalkerError): 3268 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 3269 | return 3270 | 3271 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 3272 | 3273 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 3274 | content = f.read() 3275 | 3276 | expected = """\ 3277 | bar==1 ; python_version == "3.8" 3278 | bar==2 ; python_version >= "3.9" and python_version < "4.0" 3279 | foo==1 ; python_version == "3.8" 3280 | foo==2 ; python_version >= "3.9" and python_version < "4.0" 3281 | """ 3282 | 3283 | assert content == expected 3284 | -------------------------------------------------------------------------------- /tests/test_walker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from packaging.utils import NormalizedName 6 | from poetry.core.packages.dependency import Dependency 7 | from poetry.core.packages.package import Package 8 | 9 | from poetry_plugin_export.walker import DependencyWalkerError 10 | from poetry_plugin_export.walker import walk_dependencies 11 | 12 | 13 | def test_walk_dependencies_multiple_versions_when_latest_is_not_compatible() -> None: 14 | # TODO: Support this case: 15 | # https://github.com/python-poetry/poetry-plugin-export/issues/183 16 | with pytest.raises(DependencyWalkerError): 17 | walk_dependencies( 18 | dependencies=[ 19 | Dependency("grpcio", ">=1.42.0"), 20 | Dependency("grpcio", ">=1.42.0,<=1.49.1"), 21 | Dependency("grpcio", ">=1.47.0,<2.0dev"), 22 | ], 23 | packages_by_name={ 24 | "grpcio": [Package("grpcio", "1.51.3"), Package("grpcio", "1.49.1")] 25 | }, 26 | root_package_name=NormalizedName("package-name"), 27 | ) 28 | -------------------------------------------------------------------------------- /tests/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from typing import Protocol 5 | 6 | 7 | if TYPE_CHECKING: 8 | from cleo.testers.command_tester import CommandTester 9 | from poetry.installation import Installer 10 | from poetry.installation.executor import Executor 11 | from poetry.poetry import Poetry 12 | from poetry.utils.env import Env 13 | 14 | 15 | class CommandTesterFactory(Protocol): 16 | def __call__( 17 | self, 18 | command: str, 19 | poetry: Poetry | None = None, 20 | installer: Installer | None = None, 21 | executor: Executor | None = None, 22 | environment: Env | None = None, 23 | ) -> CommandTester: ... 24 | 25 | 26 | class ProjectFactory(Protocol): 27 | def __call__( 28 | self, 29 | name: str, 30 | dependencies: dict[str, str] | None = None, 31 | dev_dependencies: dict[str, str] | None = None, 32 | pyproject_content: str | None = None, 33 | poetry_lock_content: str | None = None, 34 | install_deps: bool = True, 35 | ) -> Poetry: ... 36 | --------------------------------------------------------------------------------