├── tests ├── __init__.py ├── command │ ├── __init__.py │ ├── conftest.py │ └── test_command_export.py ├── fixtures │ ├── project_with_setup │ │ ├── my_package │ │ │ └── __init__.py │ │ ├── project_with_setup.egg-info │ │ │ ├── dependency_links.txt │ │ │ ├── top_level.txt │ │ │ ├── requires.txt │ │ │ ├── SOURCES.txt │ │ │ └── PKG-INFO │ │ └── setup.py │ ├── simple_project │ │ ├── simple_project │ │ │ └── __init__.py │ │ ├── README.rst │ │ ├── dist │ │ │ ├── simple-project-1.2.3.tar.gz │ │ │ └── simple_project-1.2.3-py2.py3-none-any.whl │ │ └── pyproject.toml │ ├── sample_project │ │ ├── README.rst │ │ └── pyproject.toml │ ├── distributions │ │ ├── demo-0.1.0.tar.gz │ │ └── demo-0.1.0-py2.py3-none-any.whl │ └── project_with_nested_local │ │ ├── quix │ │ └── pyproject.toml │ │ ├── foo │ │ └── pyproject.toml │ │ ├── bar │ │ └── pyproject.toml │ │ └── pyproject.toml ├── test_walker.py ├── types.py ├── markers.py ├── helpers.py ├── conftest.py └── test_exporter.py ├── src └── poetry_plugin_export │ ├── __init__.py │ ├── plugins.py │ ├── command.py │ ├── exporter.py │ └── walker.py ├── .pre-commit-hooks.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── .github └── workflows │ ├── release.yaml │ └── main.yaml ├── docs └── _index.md ├── README.md ├── pyproject.toml └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/command/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/project_with_setup/my_package/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/simple_project/simple_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/sample_project/README.rst: -------------------------------------------------------------------------------- 1 | My Package 2 | ========== 3 | -------------------------------------------------------------------------------- /tests/fixtures/simple_project/README.rst: -------------------------------------------------------------------------------- 1 | My Package 2 | ========== 3 | -------------------------------------------------------------------------------- /tests/fixtures/project_with_setup/project_with_setup.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/project_with_setup/project_with_setup.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | my_package 2 | -------------------------------------------------------------------------------- /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/distributions/demo-0.1.0.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/HEAD/tests/fixtures/distributions/demo-0.1.0.tar.gz -------------------------------------------------------------------------------- /tests/fixtures/distributions/demo-0.1.0-py2.py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/HEAD/tests/fixtures/distributions/demo-0.1.0-py2.py3-none-any.whl -------------------------------------------------------------------------------- /tests/fixtures/simple_project/dist/simple-project-1.2.3.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/HEAD/tests/fixtures/simple_project/dist/simple-project-1.2.3.tar.gz -------------------------------------------------------------------------------- /src/poetry_plugin_export/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from importlib import metadata 4 | 5 | 6 | __version__ = metadata.version("poetry-plugin-export") # type: ignore[no-untyped-call] 7 | -------------------------------------------------------------------------------- /tests/fixtures/simple_project/dist/simple_project-1.2.3-py2.py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-poetry/poetry-plugin-export/HEAD/tests/fixtures/simple_project/dist/simple_project-1.2.3-py2.py3-none-any.whl -------------------------------------------------------------------------------- /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_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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/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_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 | -------------------------------------------------------------------------------- /.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: v6.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.14.9 24 | hooks: 25 | - id: ruff 26 | - id: ruff-format 27 | 28 | - repo: https://github.com/woodruffw/zizmor-pre-commit 29 | rev: v1.18.0 30 | hooks: 31 | - id: zizmor 32 | -------------------------------------------------------------------------------- /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/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 | with pytest.raises(DependencyWalkerError): 15 | walk_dependencies( 16 | dependencies=[ 17 | Dependency("grpcio", ">=1.42.0"), 18 | Dependency("grpcio", ">=1.42.0,<=1.49.1"), 19 | Dependency("grpcio", ">=1.47.0,<2.0dev"), 20 | ], 21 | packages_by_name={ 22 | "grpcio": [Package("grpcio", "1.51.3"), Package("grpcio", "1.49.1")] 23 | }, 24 | root_package_name=NormalizedName("package-name"), 25 | ) 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 35 | with: 36 | persist-credentials: false 37 | 38 | - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 59 | with: 60 | name: distfiles 61 | path: dist/ 62 | 63 | - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 64 | with: 65 | print-hash: true 66 | -------------------------------------------------------------------------------- /.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.10", "3.11", "3.12", "3.13", "3.14"] 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 36 | with: 37 | persist-credentials: false 38 | 39 | - name: Set up Python ${{ matrix.python-version }} 40 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 = "MIT" 7 | readme = "README.md" 8 | requires-python = ">=3.10,<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 = ">=1.18.1" 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 | line-length = 88 49 | extend-exclude = [ 50 | "docs/*", 51 | # External to the project's coding standards 52 | "tests/**/fixtures/*", 53 | ] 54 | 55 | [tool.ruff.lint] 56 | unfixable = [ 57 | "ERA", # do not autoremove commented out code 58 | ] 59 | extend-select = [ 60 | "B", # flake8-bugbear 61 | "C4", # flake8-comprehensions 62 | "ERA", # flake8-eradicate/eradicate 63 | "I", # isort 64 | "N", # pep8-naming 65 | "PIE", # flake8-pie 66 | "PGH", # pygrep 67 | "RUF", # ruff checks 68 | "SIM", # flake8-simplify 69 | "TCH", # flake8-type-checking 70 | "TID", # flake8-tidy-imports 71 | "UP", # pyupgrade 72 | ] 73 | 74 | [tool.ruff.lint.flake8-tidy-imports] 75 | ban-relative-imports = "all" 76 | 77 | [tool.ruff.lint.isort] 78 | force-single-line = true 79 | lines-between-types = 1 80 | lines-after-imports = 2 81 | known-first-party = ["poetry_plugin_export"] 82 | required-imports = ["from __future__ import annotations"] 83 | 84 | 85 | [tool.mypy] 86 | namespace_packages = true 87 | show_error_codes = true 88 | enable_error_code = [ 89 | "ignore-without-code", 90 | "redundant-expr", 91 | "truthy-bool", 92 | ] 93 | strict = true 94 | files = ["src", "tests"] 95 | exclude = ["^tests/fixtures/"] 96 | 97 | # use of importlib-metadata backport makes it impossible to satisfy mypy 98 | # without some ignores: but we get a different set of ignores at different 99 | # python versions. 100 | # 101 | # , meanwhile suppress that 102 | # warning. 103 | [[tool.mypy.overrides]] 104 | module = [ 105 | 'poetry_plugin_export', 106 | ] 107 | warn_unused_ignores = false 108 | 109 | 110 | [tool.pytest.ini_options] 111 | addopts = "-n auto" 112 | testpaths = [ 113 | "tests" 114 | ] 115 | 116 | 117 | [build-system] 118 | requires = ["poetry-core>=2.0"] 119 | build-backend = "poetry.core.masonry.api" 120 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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[NormalizedName] = [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[NormalizedName]) -> 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | raise DependencyWalkerError( 253 | f"The `{dependency.name}` package has the following compatible" 254 | f" candidates `{compatible_candidates}`; but, the exporter dependency" 255 | f" walker previously elected `{overlapping_candidates.pop()}` which is" 256 | f" not compatible with the dependency `{dependency}`. Please relock" 257 | " with Poetry 2.0 or later to solve this issue." 258 | ) 259 | 260 | compatible_candidates = filtered_compatible_candidates 261 | 262 | return next(iter(compatible_candidates), None) 263 | 264 | 265 | def get_project_dependency_packages2( 266 | locker: Locker, 267 | project_python_marker: BaseMarker | None = None, 268 | groups: Collection[NormalizedName] = (), 269 | extras: Collection[NormalizedName] = (), 270 | ) -> Iterator[DependencyPackage]: 271 | for package, info in locker.locked_packages().items(): 272 | if not info.groups.intersection(groups): 273 | continue 274 | 275 | marker = info.get_marker(groups) 276 | if not marker.validate({"extra": extras}): 277 | continue 278 | 279 | if project_python_marker: 280 | marker = project_python_marker.intersect(marker) 281 | 282 | package.marker = marker 283 | 284 | yield DependencyPackage(dependency=package.to_dependency(), package=package) 285 | 286 | 287 | class DependencyWalkerError(Exception): 288 | pass 289 | -------------------------------------------------------------------------------- /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 packaging.utils import canonicalize_name 11 | from poetry.core.constraints.version import Version 12 | from poetry.core.packages.dependency import Dependency 13 | from poetry.core.packages.dependency_group import MAIN_GROUP 14 | from poetry.core.version.markers import MarkerUnion 15 | from poetry.core.version.markers import parse_marker 16 | from poetry.factory import Factory 17 | from poetry.packages import Locker as BaseLocker 18 | from poetry.repositories.legacy_repository import LegacyRepository 19 | from poetry.repositories.repository_pool import Priority 20 | 21 | from poetry_plugin_export.exporter import Exporter 22 | from poetry_plugin_export.walker import DependencyWalkerError 23 | from tests.markers import MARKER_CPYTHON 24 | from tests.markers import MARKER_DARWIN 25 | from tests.markers import MARKER_LINUX 26 | from tests.markers import MARKER_PY 27 | from tests.markers import MARKER_PY27 28 | from tests.markers import MARKER_PY36 29 | from tests.markers import MARKER_PY36_38 30 | from tests.markers import MARKER_PY36_ONLY 31 | from tests.markers import MARKER_PY36_PY362 32 | from tests.markers import MARKER_PY36_PY362_ALT 33 | from tests.markers import MARKER_PY37 34 | from tests.markers import MARKER_PY362_PY40 35 | from tests.markers import MARKER_PY_DARWIN 36 | from tests.markers import MARKER_PY_LINUX 37 | from tests.markers import MARKER_PY_WIN32 38 | from tests.markers import MARKER_PY_WINDOWS 39 | from tests.markers import MARKER_WIN32 40 | from tests.markers import MARKER_WINDOWS 41 | 42 | 43 | if TYPE_CHECKING: 44 | from collections.abc import Collection 45 | from pathlib import Path 46 | 47 | from packaging.utils import NormalizedName 48 | from poetry.poetry import Poetry 49 | 50 | from tests.conftest import Config 51 | 52 | 53 | DEV_GROUP = canonicalize_name("dev") 54 | 55 | 56 | class Locker(BaseLocker): 57 | def __init__(self, fixture_root: Path) -> None: 58 | super().__init__(fixture_root / "poetry.lock", {}) 59 | self._locked = True 60 | 61 | def locked(self, is_locked: bool = True) -> Locker: 62 | self._locked = is_locked 63 | 64 | return self 65 | 66 | def mock_lock_data(self, data: dict[str, Any]) -> None: 67 | self._lock_data = data 68 | 69 | def is_locked(self) -> bool: 70 | return self._locked 71 | 72 | def is_fresh(self) -> bool: 73 | return True 74 | 75 | def _get_content_hash(self) -> str: 76 | return "123456789" 77 | 78 | 79 | @pytest.fixture 80 | def locker(fixture_root: Path) -> Locker: 81 | return Locker(fixture_root) 82 | 83 | 84 | @pytest.fixture 85 | def poetry(fixture_root: Path, locker: Locker) -> Poetry: 86 | p = Factory().create_poetry(fixture_root / "sample_project") 87 | p._locker = locker 88 | 89 | return p 90 | 91 | 92 | def set_package_requires( 93 | poetry: Poetry, 94 | skip: set[str] | None = None, 95 | dev: set[str] | None = None, 96 | markers: dict[str, str] | None = None, 97 | ) -> None: 98 | skip = skip or set() 99 | dev = dev or set() 100 | packages = poetry.locker.locked_repository().packages 101 | package = poetry.package.with_dependency_groups([], only=True) 102 | for pkg in packages: 103 | if pkg.name not in skip: 104 | dep = pkg.to_dependency() 105 | if pkg.name in dev: 106 | try: 107 | dep.groups = frozenset([canonicalize_name("dev")]) 108 | except AttributeError: 109 | dep._groups = frozenset(["dev"]) # type: ignore[attr-defined] 110 | if markers and pkg.name in markers: 111 | dep._marker = parse_marker(markers[pkg.name]) 112 | package.add_dependency(dep) 113 | 114 | poetry._package = package 115 | 116 | 117 | def fix_lock_data(lock_data: dict[str, Any]) -> None: 118 | if Version.parse(lock_data["metadata"]["lock-version"]) >= Version.parse("2.1"): 119 | for locked_package in lock_data["package"]: 120 | locked_package["groups"] = ["main"] 121 | locked_package["files"] = lock_data["metadata"]["files"][ 122 | locked_package["name"] 123 | ] 124 | del lock_data["metadata"]["files"] 125 | 126 | 127 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 128 | def test_exporter_can_export_requirements_txt_with_standard_packages( 129 | tmp_path: Path, poetry: Poetry, lock_version: str 130 | ) -> None: 131 | lock_data = { 132 | "package": [ 133 | { 134 | "name": "foo", 135 | "version": "1.2.3", 136 | "optional": False, 137 | "python-versions": "*", 138 | }, 139 | { 140 | "name": "bar", 141 | "version": "4.5.6", 142 | "optional": False, 143 | "python-versions": "*", 144 | }, 145 | ], 146 | "metadata": { 147 | "lock-version": lock_version, 148 | "python-versions": "*", 149 | "content-hash": "123456789", 150 | "files": {"foo": [], "bar": []}, 151 | }, 152 | } 153 | fix_lock_data(lock_data) 154 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 155 | set_package_requires(poetry) 156 | 157 | exporter = Exporter(poetry, NullIO()) 158 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 159 | 160 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 161 | content = f.read() 162 | 163 | expected = f"""\ 164 | bar==4.5.6 ; {MARKER_PY} 165 | foo==1.2.3 ; {MARKER_PY} 166 | """ 167 | 168 | assert content == expected 169 | 170 | 171 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 172 | def test_exporter_can_export_requirements_txt_with_standard_packages_and_markers( 173 | tmp_path: Path, poetry: Poetry, lock_version: str 174 | ) -> None: 175 | lock_data: dict[str, Any] = { 176 | "package": [ 177 | { 178 | "name": "foo", 179 | "version": "1.2.3", 180 | "optional": False, 181 | "python-versions": "*", 182 | }, 183 | { 184 | "name": "bar", 185 | "version": "4.5.6", 186 | "optional": False, 187 | "python-versions": "*", 188 | }, 189 | { 190 | "name": "baz", 191 | "version": "7.8.9", 192 | "optional": False, 193 | "python-versions": "*", 194 | }, 195 | ], 196 | "metadata": { 197 | "lock-version": lock_version, 198 | "python-versions": "*", 199 | "content-hash": "123456789", 200 | "files": {"foo": [], "bar": [], "baz": []}, 201 | }, 202 | } 203 | fix_lock_data(lock_data) 204 | if lock_version == "2.1": 205 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 206 | lock_data["package"][2]["markers"] = "sys_platform == 'win32'" 207 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 208 | markers = { 209 | "foo": "python_version < '3.7'", 210 | "bar": "extra =='foo'", 211 | "baz": "sys_platform == 'win32'", 212 | } 213 | set_package_requires(poetry, markers=markers) 214 | 215 | exporter = Exporter(poetry, NullIO()) 216 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 217 | 218 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 219 | content = f.read() 220 | 221 | expected = f"""\ 222 | bar==4.5.6 ; {MARKER_PY} 223 | baz==7.8.9 ; {MARKER_PY_WIN32} 224 | foo==1.2.3 ; {MarkerUnion(MARKER_PY27, MARKER_PY36_ONLY)} 225 | """ 226 | 227 | assert content == expected 228 | 229 | 230 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 231 | def test_exporter_can_export_requirements_txt_poetry( 232 | tmp_path: Path, poetry: Poetry, lock_version: str 233 | ) -> None: 234 | """Regression test for #3254""" 235 | 236 | lock_data: dict[str, Any] = { 237 | "package": [ 238 | { 239 | "name": "poetry", 240 | "version": "1.1.4", 241 | "optional": False, 242 | "python-versions": "*", 243 | "dependencies": {"keyring": "*"}, 244 | }, 245 | { 246 | "name": "junit-xml", 247 | "version": "1.9", 248 | "optional": False, 249 | "python-versions": "*", 250 | "dependencies": {"six": "*"}, 251 | }, 252 | { 253 | "name": "keyring", 254 | "version": "21.8.0", 255 | "optional": False, 256 | "python-versions": "*", 257 | "dependencies": { 258 | "SecretStorage": { 259 | "version": "*", 260 | "markers": "sys_platform == 'linux'", 261 | } 262 | }, 263 | }, 264 | { 265 | "name": "secretstorage", 266 | "version": "3.3.0", 267 | "optional": False, 268 | "python-versions": "*", 269 | "dependencies": {"cryptography": "*"}, 270 | }, 271 | { 272 | "name": "cryptography", 273 | "version": "3.2", 274 | "optional": False, 275 | "python-versions": "*", 276 | "dependencies": {"six": "*"}, 277 | }, 278 | { 279 | "name": "six", 280 | "version": "1.15.0", 281 | "optional": False, 282 | "python-versions": "*", 283 | }, 284 | ], 285 | "metadata": { 286 | "lock-version": lock_version, 287 | "python-versions": "*", 288 | "content-hash": "123456789", 289 | "files": { 290 | "poetry": [], 291 | "keyring": [], 292 | "secretstorage": [], 293 | "cryptography": [], 294 | "six": [], 295 | "junit-xml": [], 296 | }, 297 | }, 298 | } 299 | fix_lock_data(lock_data) 300 | if lock_version == "2.1": 301 | lock_data["package"][3]["markers"] = "sys_platform == 'linux'" 302 | lock_data["package"][4]["markers"] = "sys_platform == 'linux'" 303 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 304 | set_package_requires( 305 | poetry, skip={"keyring", "secretstorage", "cryptography", "six"} 306 | ) 307 | 308 | exporter = Exporter(poetry, NullIO()) 309 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 310 | 311 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 312 | content = f.read() 313 | 314 | # The dependency graph: 315 | # junit-xml 1.9 Creates JUnit XML test result documents that can be read by tools 316 | # └── six * such as Jenkins 317 | # poetry 1.1.4 Python dependency management and packaging made easy. 318 | # ├── keyring >=21.2.0,<22.0.0 319 | # │ ├── importlib-metadata >=1 320 | # │ │ └── zipp >=0.5 321 | # │ ├── jeepney >=0.4.2 322 | # │ ├── pywin32-ctypes <0.1.0 || >0.1.0,<0.1.1 || >0.1.1 323 | # │ └── secretstorage >=3.2 -- On linux only 324 | # │ ├── cryptography >=2.0 325 | # │ │ └── six >=1.4.1 326 | # │ └── jeepney >=0.6 (circular dependency aborted here) 327 | expected = { 328 | "poetry": Dependency.create_from_pep_508(f"poetry==1.1.4; {MARKER_PY}"), 329 | "junit-xml": Dependency.create_from_pep_508(f"junit-xml==1.9 ; {MARKER_PY}"), 330 | "keyring": Dependency.create_from_pep_508(f"keyring==21.8.0 ; {MARKER_PY}"), 331 | "secretstorage": Dependency.create_from_pep_508( 332 | f"secretstorage==3.3.0 ; {MARKER_PY_LINUX}" 333 | ), 334 | "cryptography": Dependency.create_from_pep_508( 335 | f"cryptography==3.2 ; {MARKER_PY_LINUX}" 336 | ), 337 | "six": Dependency.create_from_pep_508( 338 | f"six==1.15.0 ; {MARKER_PY.union(MARKER_PY_LINUX)}" 339 | ), 340 | } 341 | 342 | for line in content.strip().split("\n"): 343 | dependency = Dependency.create_from_pep_508(line) 344 | assert dependency.name in expected 345 | expected_dependency = expected.pop(dependency.name) 346 | assert dependency == expected_dependency 347 | assert dependency.marker == expected_dependency.marker 348 | 349 | 350 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 351 | def test_exporter_can_export_requirements_txt_pyinstaller( 352 | tmp_path: Path, poetry: Poetry, lock_version: str 353 | ) -> None: 354 | """Regression test for #3254""" 355 | 356 | lock_data: dict[str, Any] = { 357 | "package": [ 358 | { 359 | "name": "pyinstaller", 360 | "version": "4.0", 361 | "optional": False, 362 | "python-versions": "*", 363 | "dependencies": { 364 | "altgraph": "*", 365 | "macholib": { 366 | "version": "*", 367 | "markers": "sys_platform == 'darwin'", 368 | }, 369 | }, 370 | }, 371 | { 372 | "name": "altgraph", 373 | "version": "0.17", 374 | "optional": False, 375 | "python-versions": "*", 376 | }, 377 | { 378 | "name": "macholib", 379 | "version": "1.8", 380 | "optional": False, 381 | "python-versions": "*", 382 | "dependencies": {"altgraph": ">=0.15"}, 383 | }, 384 | ], 385 | "metadata": { 386 | "lock-version": lock_version, 387 | "python-versions": "*", 388 | "content-hash": "123456789", 389 | "files": {"pyinstaller": [], "altgraph": [], "macholib": []}, 390 | }, 391 | } 392 | fix_lock_data(lock_data) 393 | if lock_version == "2.1": 394 | lock_data["package"][2]["markers"] = "sys_platform == 'darwin'" 395 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 396 | set_package_requires(poetry, skip={"altgraph", "macholib"}) 397 | 398 | exporter = Exporter(poetry, NullIO()) 399 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 400 | 401 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 402 | content = f.read() 403 | 404 | # Rationale for the results: 405 | # * PyInstaller has an explicit dependency on altgraph, so it must always be 406 | # installed. 407 | # * PyInstaller requires macholib on Darwin, which in turn requires altgraph. 408 | # The dependency graph: 409 | # pyinstaller 4.0 PyInstaller bundles a Python application and all its 410 | # ├── altgraph * dependencies into a single package. 411 | # ├── macholib >=1.8 -- only on Darwin 412 | # │ └── altgraph >=0.15 413 | expected = { 414 | "pyinstaller": Dependency.create_from_pep_508( 415 | f"pyinstaller==4.0 ; {MARKER_PY}" 416 | ), 417 | "altgraph": Dependency.create_from_pep_508( 418 | f"altgraph==0.17 ; {MARKER_PY.union(MARKER_PY_DARWIN)}" 419 | ), 420 | "macholib": Dependency.create_from_pep_508( 421 | f"macholib==1.8 ; {MARKER_PY_DARWIN}" 422 | ), 423 | } 424 | 425 | for line in content.strip().split("\n"): 426 | dependency = Dependency.create_from_pep_508(line) 427 | assert dependency.name in expected 428 | expected_dependency = expected.pop(dependency.name) 429 | assert dependency == expected_dependency 430 | assert dependency.marker == expected_dependency.marker 431 | 432 | 433 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 434 | def test_exporter_can_export_requirements_txt_with_nested_packages_and_markers( 435 | tmp_path: Path, poetry: Poetry, lock_version: str 436 | ) -> None: 437 | lock_data: dict[str, Any] = { 438 | "package": [ 439 | { 440 | "name": "a", 441 | "version": "1.2.3", 442 | "optional": False, 443 | "python-versions": "*", 444 | "dependencies": { 445 | "b": { 446 | "version": ">=0.0.0", 447 | "markers": "platform_system == 'Windows'", 448 | }, 449 | "c": { 450 | "version": ">=0.0.0", 451 | "markers": "sys_platform == 'win32'", 452 | }, 453 | }, 454 | }, 455 | { 456 | "name": "b", 457 | "version": "4.5.6", 458 | "optional": False, 459 | "python-versions": "*", 460 | "dependencies": {"d": ">=0.0.0"}, 461 | }, 462 | { 463 | "name": "c", 464 | "version": "7.8.9", 465 | "optional": False, 466 | "python-versions": "*", 467 | "dependencies": {"d": ">=0.0.0"}, 468 | }, 469 | { 470 | "name": "d", 471 | "version": "0.0.1", 472 | "optional": False, 473 | "python-versions": "*", 474 | }, 475 | ], 476 | "metadata": { 477 | "lock-version": lock_version, 478 | "python-versions": "*", 479 | "content-hash": "123456789", 480 | "files": {"a": [], "b": [], "c": [], "d": []}, 481 | }, 482 | } 483 | fix_lock_data(lock_data) 484 | if lock_version == "2.1": 485 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 486 | lock_data["package"][1]["markers"] = ( 487 | "python_version < '3.7' and platform_system == 'Windows'" 488 | ) 489 | lock_data["package"][2]["markers"] = ( 490 | "python_version < '3.7' and sys_platform == 'win32'" 491 | ) 492 | lock_data["package"][3]["markers"] = ( 493 | "python_version < '3.7' and platform_system == 'Windows'" 494 | " or python_version < '3.7' and sys_platform == 'win32'" 495 | ) 496 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 497 | set_package_requires( 498 | poetry, skip={"b", "c", "d"}, markers={"a": "python_version < '3.7'"} 499 | ) 500 | 501 | exporter = Exporter(poetry, NullIO()) 502 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 503 | 504 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 505 | content = f.read() 506 | 507 | marker_py = MarkerUnion(MARKER_PY27, MARKER_PY36_ONLY) 508 | marker_py_win32 = marker_py.intersect(MARKER_WIN32) 509 | marker_py_windows = marker_py.intersect(MARKER_WINDOWS) 510 | 511 | expected = { 512 | "a": Dependency.create_from_pep_508(f"a==1.2.3 ; {marker_py}"), 513 | "b": Dependency.create_from_pep_508(f"b==4.5.6 ; {marker_py_windows}"), 514 | "c": Dependency.create_from_pep_508(f"c==7.8.9 ; {marker_py_win32}"), 515 | "d": Dependency.create_from_pep_508( 516 | f"d==0.0.1 ; {marker_py_windows.union(marker_py_win32)}" 517 | ), 518 | } 519 | 520 | for line in content.strip().split("\n"): 521 | dependency = Dependency.create_from_pep_508(line) 522 | assert dependency.name in expected 523 | expected_dependency = expected.pop(dependency.name) 524 | assert dependency == expected_dependency 525 | assert dependency.marker == expected_dependency.marker 526 | 527 | assert expected == {} 528 | 529 | 530 | @pytest.mark.parametrize( 531 | ["dev", "lines"], 532 | [ 533 | ( 534 | False, 535 | [f"a==1.2.3 ; {MarkerUnion(MARKER_PY27, MARKER_PY36_38)}"], 536 | ), 537 | ( 538 | True, 539 | [ 540 | f"a==1.2.3 ; {MarkerUnion(MARKER_PY27, MARKER_PY36_38.union(MARKER_PY36))}", 541 | f"b==4.5.6 ; {MARKER_PY}", 542 | ], 543 | ), 544 | ], 545 | ) 546 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 547 | def test_exporter_can_export_requirements_txt_with_nested_packages_and_markers_any( 548 | tmp_path: Path, poetry: Poetry, dev: bool, lines: list[str], lock_version: str 549 | ) -> None: 550 | lock_data: dict[str, Any] = { 551 | "package": [ 552 | { 553 | "name": "a", 554 | "version": "1.2.3", 555 | "optional": False, 556 | "python-versions": "*", 557 | }, 558 | { 559 | "name": "b", 560 | "version": "4.5.6", 561 | "optional": False, 562 | "python-versions": "*", 563 | "dependencies": {"a": ">=1.2.3"}, 564 | }, 565 | ], 566 | "metadata": { 567 | "lock-version": lock_version, 568 | "python-versions": "*", 569 | "content-hash": "123456789", 570 | "files": {"a": [], "b": []}, 571 | }, 572 | } 573 | fix_lock_data(lock_data) 574 | if lock_version == "2.1": 575 | lock_data["package"][0]["groups"] = ["main", "dev"] 576 | lock_data["package"][0]["markers"] = {"main": "python_version < '3.8'"} 577 | lock_data["package"][1]["groups"] = ["dev"] 578 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 579 | 580 | root = poetry.package.with_dependency_groups([], only=True) 581 | root.add_dependency( 582 | Factory.create_dependency( 583 | name="a", constraint={"version": "^1.2.3", "python": "<3.8"} 584 | ) 585 | ) 586 | root.add_dependency( 587 | Factory.create_dependency( 588 | name="b", constraint={"version": "^4.5.6"}, groups=["dev"] 589 | ) 590 | ) 591 | poetry._package = root 592 | 593 | exporter = Exporter(poetry, NullIO()) 594 | if dev: 595 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 596 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 597 | 598 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 599 | content = f.read() 600 | 601 | assert content.strip() == "\n".join(lines) 602 | 603 | 604 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 605 | def test_exporter_can_export_requirements_txt_with_standard_packages_and_hashes( 606 | tmp_path: Path, poetry: Poetry, lock_version: str 607 | ) -> None: 608 | lock_data: dict[str, Any] = { 609 | "package": [ 610 | { 611 | "name": "foo", 612 | "version": "1.2.3", 613 | "optional": False, 614 | "python-versions": "*", 615 | }, 616 | { 617 | "name": "bar", 618 | "version": "4.5.6", 619 | "optional": False, 620 | "python-versions": "*", 621 | }, 622 | ], 623 | "metadata": { 624 | "lock-version": lock_version, 625 | "python-versions": "*", 626 | "content-hash": "123456789", 627 | "files": { 628 | "foo": [{"name": "foo.whl", "hash": "12345"}], 629 | "bar": [{"name": "bar.whl", "hash": "67890"}], 630 | }, 631 | }, 632 | } 633 | fix_lock_data(lock_data) 634 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 635 | set_package_requires(poetry) 636 | 637 | exporter = Exporter(poetry, NullIO()) 638 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 639 | 640 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 641 | content = f.read() 642 | 643 | expected = f"""\ 644 | bar==4.5.6 ; {MARKER_PY} \\ 645 | --hash=sha256:67890 646 | foo==1.2.3 ; {MARKER_PY} \\ 647 | --hash=sha256:12345 648 | """ 649 | 650 | assert content == expected 651 | 652 | 653 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 654 | def test_exporter_can_export_requirements_txt_with_standard_packages_and_sorted_hashes( 655 | tmp_path: Path, poetry: Poetry, lock_version: str 656 | ) -> None: 657 | lock_data = { 658 | "package": [ 659 | { 660 | "name": "foo", 661 | "version": "1.2.3", 662 | "optional": False, 663 | "python-versions": "*", 664 | }, 665 | { 666 | "name": "bar", 667 | "version": "4.5.6", 668 | "optional": False, 669 | "python-versions": "*", 670 | }, 671 | ], 672 | "metadata": { 673 | "lock-version": lock_version, 674 | "python-versions": "*", 675 | "content-hash": "123456789", 676 | "files": { 677 | "foo": [ 678 | {"name": "foo1.whl", "hash": "67890"}, 679 | {"name": "foo2.whl", "hash": "12345"}, 680 | ], 681 | "bar": [ 682 | {"name": "bar1.whl", "hash": "67890"}, 683 | {"name": "bar2.whl", "hash": "12345"}, 684 | ], 685 | }, 686 | }, 687 | } 688 | fix_lock_data(lock_data) 689 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 690 | set_package_requires(poetry) 691 | 692 | exporter = Exporter(poetry, NullIO()) 693 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 694 | 695 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 696 | content = f.read() 697 | 698 | expected = f"""\ 699 | bar==4.5.6 ; {MARKER_PY} \\ 700 | --hash=sha256:12345 \\ 701 | --hash=sha256:67890 702 | foo==1.2.3 ; {MARKER_PY} \\ 703 | --hash=sha256:12345 \\ 704 | --hash=sha256:67890 705 | """ 706 | 707 | assert content == expected 708 | 709 | 710 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 711 | def test_exporter_can_export_requirements_txt_with_standard_packages_and_hashes_disabled( 712 | tmp_path: Path, poetry: Poetry, lock_version: str 713 | ) -> None: 714 | lock_data = { 715 | "package": [ 716 | { 717 | "name": "foo", 718 | "version": "1.2.3", 719 | "optional": False, 720 | "python-versions": "*", 721 | }, 722 | { 723 | "name": "bar", 724 | "version": "4.5.6", 725 | "optional": False, 726 | "python-versions": "*", 727 | }, 728 | ], 729 | "metadata": { 730 | "lock-version": lock_version, 731 | "python-versions": "*", 732 | "content-hash": "123456789", 733 | "files": { 734 | "foo": [{"name": "foo.whl", "hash": "12345"}], 735 | "bar": [{"name": "bar.whl", "hash": "67890"}], 736 | }, 737 | }, 738 | } 739 | fix_lock_data(lock_data) 740 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 741 | set_package_requires(poetry) 742 | 743 | exporter = Exporter(poetry, NullIO()) 744 | exporter.with_hashes(False) 745 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 746 | 747 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 748 | content = f.read() 749 | 750 | expected = f"""\ 751 | bar==4.5.6 ; {MARKER_PY} 752 | foo==1.2.3 ; {MARKER_PY} 753 | """ 754 | 755 | assert content == expected 756 | 757 | 758 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 759 | def test_exporter_exports_requirements_txt_without_dev_packages_by_default( 760 | tmp_path: Path, poetry: Poetry, lock_version: str 761 | ) -> None: 762 | lock_data: dict[str, Any] = { 763 | "package": [ 764 | { 765 | "name": "foo", 766 | "version": "1.2.3", 767 | "optional": False, 768 | "python-versions": "*", 769 | }, 770 | { 771 | "name": "bar", 772 | "version": "4.5.6", 773 | "optional": False, 774 | "python-versions": "*", 775 | }, 776 | ], 777 | "metadata": { 778 | "lock-version": lock_version, 779 | "python-versions": "*", 780 | "content-hash": "123456789", 781 | "files": { 782 | "foo": [{"name": "foo.whl", "hash": "12345"}], 783 | "bar": [{"name": "bar.whl", "hash": "67890"}], 784 | }, 785 | }, 786 | } 787 | fix_lock_data(lock_data) 788 | if lock_version == "2.1": 789 | lock_data["package"][1]["groups"] = ["dev"] 790 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 791 | set_package_requires(poetry, dev={"bar"}) 792 | 793 | exporter = Exporter(poetry, NullIO()) 794 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 795 | 796 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 797 | content = f.read() 798 | 799 | expected = f"""\ 800 | foo==1.2.3 ; {MARKER_PY} \\ 801 | --hash=sha256:12345 802 | """ 803 | 804 | assert content == expected 805 | 806 | 807 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 808 | def test_exporter_exports_requirements_txt_with_dev_packages_if_opted_in( 809 | tmp_path: Path, poetry: Poetry, lock_version: str 810 | ) -> None: 811 | lock_data: dict[str, Any] = { 812 | "package": [ 813 | { 814 | "name": "foo", 815 | "version": "1.2.3", 816 | "optional": False, 817 | "python-versions": "*", 818 | }, 819 | { 820 | "name": "bar", 821 | "version": "4.5.6", 822 | "optional": False, 823 | "python-versions": "*", 824 | }, 825 | ], 826 | "metadata": { 827 | "lock-version": lock_version, 828 | "python-versions": "*", 829 | "content-hash": "123456789", 830 | "files": { 831 | "foo": [{"name": "foo.whl", "hash": "12345"}], 832 | "bar": [{"name": "bar.whl", "hash": "67890"}], 833 | }, 834 | }, 835 | } 836 | fix_lock_data(lock_data) 837 | if lock_version == "2.1": 838 | lock_data["package"][1]["groups"] = ["dev"] 839 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 840 | set_package_requires(poetry, dev={"bar"}) 841 | 842 | exporter = Exporter(poetry, NullIO()) 843 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 844 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 845 | 846 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 847 | content = f.read() 848 | 849 | expected = f"""\ 850 | bar==4.5.6 ; {MARKER_PY} \\ 851 | --hash=sha256:67890 852 | foo==1.2.3 ; {MARKER_PY} \\ 853 | --hash=sha256:12345 854 | """ 855 | 856 | assert content == expected 857 | 858 | 859 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 860 | def test_exporter_exports_requirements_txt_without_groups_if_set_explicitly( 861 | tmp_path: Path, poetry: Poetry, lock_version: str 862 | ) -> None: 863 | lock_data: dict[str, Any] = { 864 | "package": [ 865 | { 866 | "name": "foo", 867 | "version": "1.2.3", 868 | "optional": False, 869 | "python-versions": "*", 870 | }, 871 | { 872 | "name": "bar", 873 | "version": "4.5.6", 874 | "optional": False, 875 | "python-versions": "*", 876 | }, 877 | ], 878 | "metadata": { 879 | "lock-version": lock_version, 880 | "python-versions": "*", 881 | "content-hash": "123456789", 882 | "files": { 883 | "foo": [{"name": "foo.whl", "hash": "12345"}], 884 | "bar": [{"name": "bar.whl", "hash": "67890"}], 885 | }, 886 | }, 887 | } 888 | fix_lock_data(lock_data) 889 | if lock_version == "2.1": 890 | lock_data["package"][1]["groups"] = ["dev"] 891 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 892 | set_package_requires(poetry, dev={"bar"}) 893 | 894 | exporter = Exporter(poetry, NullIO()) 895 | exporter.only_groups([]) 896 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 897 | 898 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 899 | content = f.read() 900 | 901 | assert content == "\n" 902 | 903 | 904 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 905 | def test_exporter_exports_requirements_txt_without_optional_packages( 906 | tmp_path: Path, poetry: Poetry, lock_version: str 907 | ) -> None: 908 | lock_data: dict[str, Any] = { 909 | "package": [ 910 | { 911 | "name": "foo", 912 | "version": "1.2.3", 913 | "optional": False, 914 | "python-versions": "*", 915 | }, 916 | { 917 | "name": "bar", 918 | "version": "4.5.6", 919 | "optional": True, 920 | "python-versions": "*", 921 | }, 922 | ], 923 | "metadata": { 924 | "lock-version": lock_version, 925 | "python-versions": "*", 926 | "content-hash": "123456789", 927 | "files": { 928 | "foo": [{"name": "foo.whl", "hash": "12345"}], 929 | "bar": [{"name": "bar.whl", "hash": "67890"}], 930 | }, 931 | }, 932 | } 933 | fix_lock_data(lock_data) 934 | if lock_version == "2.1": 935 | lock_data["package"][1]["groups"] = ["dev"] 936 | lock_data["package"][1]["markers"] = 'extra == "feature-bar"' 937 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 938 | set_package_requires(poetry, dev={"bar"}) 939 | 940 | exporter = Exporter(poetry, NullIO()) 941 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 942 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 943 | 944 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 945 | content = f.read() 946 | 947 | expected = f"""\ 948 | foo==1.2.3 ; {MARKER_PY} \\ 949 | --hash=sha256:12345 950 | """ 951 | 952 | assert content == expected 953 | 954 | 955 | @pytest.mark.parametrize( 956 | ["extras", "lines"], 957 | [ 958 | ( 959 | ["feature-bar"], 960 | [ 961 | f"bar==4.5.6 ; {MARKER_PY}", 962 | f"foo==1.2.3 ; {MARKER_PY}", 963 | f"spam==0.1.0 ; {MARKER_PY}", 964 | ], 965 | ), 966 | ], 967 | ) 968 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 969 | def test_exporter_exports_requirements_txt_with_optional_packages( 970 | tmp_path: Path, 971 | poetry: Poetry, 972 | extras: Collection[NormalizedName], 973 | lines: list[str], 974 | lock_version: str, 975 | ) -> None: 976 | lock_data: dict[str, Any] = { 977 | "package": [ 978 | { 979 | "name": "foo", 980 | "version": "1.2.3", 981 | "optional": False, 982 | "python-versions": "*", 983 | }, 984 | { 985 | "name": "bar", 986 | "version": "4.5.6", 987 | "optional": True, 988 | "python-versions": "*", 989 | "dependencies": {"spam": ">=0.1"}, 990 | }, 991 | { 992 | "name": "spam", 993 | "version": "0.1.0", 994 | "optional": True, 995 | "python-versions": "*", 996 | }, 997 | ], 998 | "metadata": { 999 | "lock-version": lock_version, 1000 | "python-versions": "*", 1001 | "content-hash": "123456789", 1002 | "files": { 1003 | "foo": [{"name": "foo.whl", "hash": "12345"}], 1004 | "bar": [{"name": "bar.whl", "hash": "67890"}], 1005 | "spam": [{"name": "spam.whl", "hash": "abcde"}], 1006 | }, 1007 | }, 1008 | "extras": {"feature_bar": ["bar"]}, 1009 | } 1010 | fix_lock_data(lock_data) 1011 | if lock_version == "2.1": 1012 | lock_data["package"][1]["markers"] = 'extra == "feature-bar"' 1013 | lock_data["package"][2]["markers"] = 'extra == "feature-bar"' 1014 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1015 | set_package_requires(poetry) 1016 | 1017 | exporter = Exporter(poetry, NullIO()) 1018 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 1019 | exporter.with_hashes(False) 1020 | exporter.with_extras(extras) 1021 | exporter.export( 1022 | "requirements.txt", 1023 | tmp_path, 1024 | "requirements.txt", 1025 | ) 1026 | 1027 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1028 | content = f.read() 1029 | 1030 | expected = "\n".join(lines) 1031 | 1032 | assert content.strip() == expected 1033 | 1034 | 1035 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1036 | def test_exporter_can_export_requirements_txt_with_git_packages( 1037 | tmp_path: Path, poetry: Poetry, lock_version: str 1038 | ) -> None: 1039 | lock_data = { 1040 | "package": [ 1041 | { 1042 | "name": "foo", 1043 | "version": "1.2.3", 1044 | "optional": False, 1045 | "python-versions": "*", 1046 | "source": { 1047 | "type": "git", 1048 | "url": "https://github.com/foo/foo.git", 1049 | "reference": "123456", 1050 | "resolved_reference": "abcdef", 1051 | }, 1052 | } 1053 | ], 1054 | "metadata": { 1055 | "lock-version": lock_version, 1056 | "python-versions": "*", 1057 | "content-hash": "123456789", 1058 | "files": {"foo": []}, 1059 | }, 1060 | } 1061 | fix_lock_data(lock_data) 1062 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1063 | set_package_requires(poetry) 1064 | 1065 | exporter = Exporter(poetry, NullIO()) 1066 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1067 | 1068 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1069 | content = f.read() 1070 | 1071 | expected = f"""\ 1072 | foo @ git+https://github.com/foo/foo.git@abcdef ; {MARKER_PY} 1073 | """ 1074 | 1075 | assert content == expected 1076 | 1077 | 1078 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1079 | def test_exporter_can_export_requirements_txt_with_nested_packages( 1080 | tmp_path: Path, poetry: Poetry, lock_version: str 1081 | ) -> None: 1082 | lock_data = { 1083 | "package": [ 1084 | { 1085 | "name": "foo", 1086 | "version": "1.2.3", 1087 | "optional": False, 1088 | "python-versions": "*", 1089 | "source": { 1090 | "type": "git", 1091 | "url": "https://github.com/foo/foo.git", 1092 | "reference": "123456", 1093 | "resolved_reference": "abcdef", 1094 | }, 1095 | }, 1096 | { 1097 | "name": "bar", 1098 | "version": "4.5.6", 1099 | "optional": False, 1100 | "python-versions": "*", 1101 | "dependencies": { 1102 | "foo": { 1103 | "git": "https://github.com/foo/foo.git", 1104 | "rev": "123456", 1105 | } 1106 | }, 1107 | }, 1108 | ], 1109 | "metadata": { 1110 | "lock-version": lock_version, 1111 | "python-versions": "*", 1112 | "content-hash": "123456789", 1113 | "files": {"foo": [], "bar": []}, 1114 | }, 1115 | } 1116 | fix_lock_data(lock_data) 1117 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1118 | set_package_requires(poetry, skip={"foo"}) 1119 | 1120 | exporter = Exporter(poetry, NullIO()) 1121 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1122 | 1123 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1124 | content = f.read() 1125 | 1126 | expected = f"""\ 1127 | bar==4.5.6 ; {MARKER_PY} 1128 | foo @ git+https://github.com/foo/foo.git@abcdef ; {MARKER_PY} 1129 | """ 1130 | 1131 | assert content == expected 1132 | 1133 | 1134 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1135 | def test_exporter_can_export_requirements_txt_with_nested_packages_cyclic( 1136 | tmp_path: Path, poetry: Poetry, lock_version: str 1137 | ) -> None: 1138 | lock_data = { 1139 | "package": [ 1140 | { 1141 | "name": "foo", 1142 | "version": "1.2.3", 1143 | "optional": False, 1144 | "python-versions": "*", 1145 | "dependencies": {"bar": {"version": "4.5.6"}}, 1146 | }, 1147 | { 1148 | "name": "bar", 1149 | "version": "4.5.6", 1150 | "optional": False, 1151 | "python-versions": "*", 1152 | "dependencies": {"baz": {"version": "7.8.9"}}, 1153 | }, 1154 | { 1155 | "name": "baz", 1156 | "version": "7.8.9", 1157 | "optional": False, 1158 | "python-versions": "*", 1159 | "dependencies": {"foo": {"version": "1.2.3"}}, 1160 | }, 1161 | ], 1162 | "metadata": { 1163 | "lock-version": lock_version, 1164 | "python-versions": "*", 1165 | "content-hash": "123456789", 1166 | "files": {"foo": [], "bar": [], "baz": []}, 1167 | }, 1168 | } 1169 | fix_lock_data(lock_data) 1170 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1171 | set_package_requires(poetry, skip={"bar", "baz"}) 1172 | 1173 | exporter = Exporter(poetry, NullIO()) 1174 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1175 | 1176 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1177 | content = f.read() 1178 | 1179 | expected = f"""\ 1180 | bar==4.5.6 ; {MARKER_PY} 1181 | baz==7.8.9 ; {MARKER_PY} 1182 | foo==1.2.3 ; {MARKER_PY} 1183 | """ 1184 | 1185 | assert content == expected 1186 | 1187 | 1188 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1189 | def test_exporter_can_export_requirements_txt_with_circular_root_dependency( 1190 | tmp_path: Path, poetry: Poetry, lock_version: str 1191 | ) -> None: 1192 | lock_data = { 1193 | "package": [ 1194 | { 1195 | "name": "foo", 1196 | "version": "1.2.3", 1197 | "optional": False, 1198 | "python-versions": "*", 1199 | "dependencies": {poetry.package.pretty_name: {"version": "1.2.3"}}, 1200 | }, 1201 | ], 1202 | "metadata": { 1203 | "lock-version": lock_version, 1204 | "python-versions": "*", 1205 | "content-hash": "123456789", 1206 | "files": {"foo": []}, 1207 | }, 1208 | } 1209 | fix_lock_data(lock_data) 1210 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1211 | set_package_requires(poetry) 1212 | 1213 | exporter = Exporter(poetry, NullIO()) 1214 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1215 | 1216 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1217 | content = f.read() 1218 | 1219 | expected = f"""\ 1220 | foo==1.2.3 ; {MARKER_PY} 1221 | """ 1222 | 1223 | assert content == expected 1224 | 1225 | 1226 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1227 | def test_exporter_can_export_requirements_txt_with_nested_packages_and_multiple_markers( 1228 | tmp_path: Path, poetry: Poetry, lock_version: str 1229 | ) -> None: 1230 | lock_data: dict[str, Any] = { 1231 | "package": [ 1232 | { 1233 | "name": "foo", 1234 | "version": "1.2.3", 1235 | "optional": False, 1236 | "python-versions": "*", 1237 | "dependencies": { 1238 | "bar": [ 1239 | { 1240 | "version": ">=1.2.3,<7.8.10", 1241 | "markers": 'platform_system != "Windows"', 1242 | }, 1243 | { 1244 | "version": ">=4.5.6,<7.8.10", 1245 | "markers": 'platform_system == "Windows"', 1246 | }, 1247 | ] 1248 | }, 1249 | }, 1250 | { 1251 | "name": "bar", 1252 | "version": "7.8.9", 1253 | "optional": True, 1254 | "python-versions": "*", 1255 | "dependencies": { 1256 | "baz": { 1257 | "version": "!=10.11.12", 1258 | "markers": 'platform_system == "Windows"', 1259 | } 1260 | }, 1261 | }, 1262 | { 1263 | "name": "baz", 1264 | "version": "10.11.13", 1265 | "optional": True, 1266 | "python-versions": "*", 1267 | }, 1268 | ], 1269 | "metadata": { 1270 | "lock-version": lock_version, 1271 | "python-versions": "*", 1272 | "content-hash": "123456789", 1273 | "files": {"foo": [], "bar": [], "baz": []}, 1274 | }, 1275 | } 1276 | fix_lock_data(lock_data) 1277 | if lock_version == "2.1": 1278 | lock_data["package"][2]["markers"] = 'platform_system == "Windows"' 1279 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1280 | set_package_requires(poetry) 1281 | 1282 | exporter = Exporter(poetry, NullIO()) 1283 | exporter.with_hashes(False) 1284 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1285 | 1286 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1287 | content = f.read() 1288 | 1289 | marker_py_not_windows = MARKER_PY.intersect( 1290 | parse_marker('platform_system != "Windows"') 1291 | ) 1292 | expected = f"""\ 1293 | bar==7.8.9 ; {marker_py_not_windows.union(MARKER_PY_WINDOWS)} 1294 | baz==10.11.13 ; {MARKER_PY_WINDOWS} 1295 | foo==1.2.3 ; {MARKER_PY} 1296 | """ 1297 | 1298 | assert content == expected 1299 | 1300 | 1301 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1302 | def test_exporter_can_export_requirements_txt_with_git_packages_and_markers( 1303 | tmp_path: Path, poetry: Poetry, lock_version: str 1304 | ) -> None: 1305 | lock_data: dict[str, Any] = { 1306 | "package": [ 1307 | { 1308 | "name": "foo", 1309 | "version": "1.2.3", 1310 | "optional": False, 1311 | "python-versions": "*", 1312 | "source": { 1313 | "type": "git", 1314 | "url": "https://github.com/foo/foo.git", 1315 | "reference": "123456", 1316 | "resolved_reference": "abcdef", 1317 | }, 1318 | } 1319 | ], 1320 | "metadata": { 1321 | "lock-version": lock_version, 1322 | "python-versions": "*", 1323 | "content-hash": "123456789", 1324 | "files": {"foo": []}, 1325 | }, 1326 | } 1327 | fix_lock_data(lock_data) 1328 | if lock_version == "2.1": 1329 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 1330 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1331 | set_package_requires(poetry, markers={"foo": "python_version < '3.7'"}) 1332 | 1333 | exporter = Exporter(poetry, NullIO()) 1334 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1335 | 1336 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1337 | content = f.read() 1338 | 1339 | expected = f"""\ 1340 | foo @ git+https://github.com/foo/foo.git@abcdef ; {MARKER_PY27.union(MARKER_PY36_ONLY)} 1341 | """ 1342 | 1343 | assert content == expected 1344 | 1345 | 1346 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1347 | def test_exporter_can_export_requirements_txt_with_directory_packages( 1348 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1349 | ) -> None: 1350 | lock_data = { 1351 | "package": [ 1352 | { 1353 | "name": "foo", 1354 | "version": "1.2.3", 1355 | "optional": False, 1356 | "python-versions": "*", 1357 | "source": { 1358 | "type": "directory", 1359 | "url": "sample_project", 1360 | "reference": "", 1361 | }, 1362 | } 1363 | ], 1364 | "metadata": { 1365 | "lock-version": lock_version, 1366 | "python-versions": "*", 1367 | "content-hash": "123456789", 1368 | "files": {"foo": []}, 1369 | }, 1370 | } 1371 | fix_lock_data(lock_data) 1372 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1373 | set_package_requires(poetry) 1374 | 1375 | exporter = Exporter(poetry, NullIO()) 1376 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1377 | 1378 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1379 | content = f.read() 1380 | 1381 | expected = f"""\ 1382 | foo @ {fixture_root_uri}/sample_project ; {MARKER_PY} 1383 | """ 1384 | 1385 | assert content == expected 1386 | 1387 | 1388 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1389 | def test_exporter_can_export_requirements_txt_with_directory_packages_editable( 1390 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1391 | ) -> None: 1392 | lock_data = { 1393 | "package": [ 1394 | { 1395 | "name": "foo", 1396 | "version": "1.2.3", 1397 | "optional": False, 1398 | "python-versions": "*", 1399 | "develop": True, 1400 | "source": { 1401 | "type": "directory", 1402 | "url": "sample_project", 1403 | "reference": "", 1404 | }, 1405 | } 1406 | ], 1407 | "metadata": { 1408 | "lock-version": lock_version, 1409 | "python-versions": "*", 1410 | "content-hash": "123456789", 1411 | "files": {"foo": []}, 1412 | }, 1413 | } 1414 | fix_lock_data(lock_data) 1415 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1416 | set_package_requires(poetry) 1417 | 1418 | exporter = Exporter(poetry, NullIO()) 1419 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1420 | 1421 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1422 | content = f.read() 1423 | 1424 | expected = f"""\ 1425 | -e {fixture_root_uri}/sample_project ; {MARKER_PY} 1426 | """ 1427 | 1428 | assert content == expected 1429 | 1430 | 1431 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1432 | def test_exporter_can_export_requirements_txt_with_nested_directory_packages( 1433 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1434 | ) -> None: 1435 | lock_data = { 1436 | "package": [ 1437 | { 1438 | "name": "foo", 1439 | "version": "1.2.3", 1440 | "optional": False, 1441 | "python-versions": "*", 1442 | "source": { 1443 | "type": "directory", 1444 | "url": "sample_project", 1445 | "reference": "", 1446 | }, 1447 | }, 1448 | { 1449 | "name": "bar", 1450 | "version": "4.5.6", 1451 | "optional": False, 1452 | "python-versions": "*", 1453 | "source": { 1454 | "type": "directory", 1455 | "url": "sample_project/../project_with_nested_local/bar", 1456 | "reference": "", 1457 | }, 1458 | }, 1459 | { 1460 | "name": "baz", 1461 | "version": "7.8.9", 1462 | "optional": False, 1463 | "python-versions": "*", 1464 | "source": { 1465 | "type": "directory", 1466 | "url": "sample_project/../project_with_nested_local/bar/..", 1467 | "reference": "", 1468 | }, 1469 | }, 1470 | ], 1471 | "metadata": { 1472 | "lock-version": lock_version, 1473 | "python-versions": "*", 1474 | "content-hash": "123456789", 1475 | "files": {"foo": [], "bar": [], "baz": []}, 1476 | }, 1477 | } 1478 | fix_lock_data(lock_data) 1479 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1480 | set_package_requires(poetry) 1481 | 1482 | exporter = Exporter(poetry, NullIO()) 1483 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1484 | 1485 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1486 | content = f.read() 1487 | 1488 | expected = f"""\ 1489 | bar @ {fixture_root_uri}/project_with_nested_local/bar ; {MARKER_PY} 1490 | baz @ {fixture_root_uri}/project_with_nested_local ; {MARKER_PY} 1491 | foo @ {fixture_root_uri}/sample_project ; {MARKER_PY} 1492 | """ 1493 | 1494 | assert content == expected 1495 | 1496 | 1497 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1498 | def test_exporter_can_export_requirements_txt_with_directory_packages_and_markers( 1499 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1500 | ) -> None: 1501 | lock_data: dict[str, Any] = { 1502 | "package": [ 1503 | { 1504 | "name": "foo", 1505 | "version": "1.2.3", 1506 | "optional": False, 1507 | "python-versions": "*", 1508 | "source": { 1509 | "type": "directory", 1510 | "url": "sample_project", 1511 | "reference": "", 1512 | }, 1513 | } 1514 | ], 1515 | "metadata": { 1516 | "lock-version": lock_version, 1517 | "python-versions": "*", 1518 | "content-hash": "123456789", 1519 | "files": {"foo": []}, 1520 | }, 1521 | } 1522 | fix_lock_data(lock_data) 1523 | if lock_version == "2.1": 1524 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 1525 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1526 | set_package_requires(poetry, markers={"foo": "python_version < '3.7'"}) 1527 | 1528 | exporter = Exporter(poetry, NullIO()) 1529 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1530 | 1531 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1532 | content = f.read() 1533 | 1534 | expected = f"""\ 1535 | foo @ {fixture_root_uri}/sample_project ;\ 1536 | {MARKER_PY27.union(MARKER_PY36_ONLY)} 1537 | """ 1538 | 1539 | assert content == expected 1540 | 1541 | 1542 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1543 | def test_exporter_can_export_requirements_txt_with_file_packages( 1544 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1545 | ) -> None: 1546 | lock_data = { 1547 | "package": [ 1548 | { 1549 | "name": "foo", 1550 | "version": "1.2.3", 1551 | "optional": False, 1552 | "python-versions": "*", 1553 | "source": { 1554 | "type": "file", 1555 | "url": "distributions/demo-0.1.0.tar.gz", 1556 | "reference": "", 1557 | }, 1558 | } 1559 | ], 1560 | "metadata": { 1561 | "lock-version": lock_version, 1562 | "python-versions": "*", 1563 | "content-hash": "123456789", 1564 | "files": {"foo": []}, 1565 | }, 1566 | } 1567 | fix_lock_data(lock_data) 1568 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1569 | set_package_requires(poetry) 1570 | 1571 | exporter = Exporter(poetry, NullIO()) 1572 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1573 | 1574 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1575 | content = f.read() 1576 | 1577 | expected = f"""\ 1578 | foo @ {fixture_root_uri}/distributions/demo-0.1.0.tar.gz ;\ 1579 | {MARKER_PY} 1580 | """ 1581 | 1582 | assert content == expected 1583 | 1584 | 1585 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1586 | def test_exporter_can_export_requirements_txt_with_file_packages_and_markers( 1587 | tmp_path: Path, poetry: Poetry, fixture_root_uri: str, lock_version: str 1588 | ) -> None: 1589 | lock_data: dict[str, Any] = { 1590 | "package": [ 1591 | { 1592 | "name": "foo", 1593 | "version": "1.2.3", 1594 | "optional": False, 1595 | "python-versions": "*", 1596 | "source": { 1597 | "type": "file", 1598 | "url": "distributions/demo-0.1.0.tar.gz", 1599 | "reference": "", 1600 | }, 1601 | } 1602 | ], 1603 | "metadata": { 1604 | "lock-version": lock_version, 1605 | "python-versions": "*", 1606 | "content-hash": "123456789", 1607 | "files": {"foo": []}, 1608 | }, 1609 | } 1610 | fix_lock_data(lock_data) 1611 | if lock_version == "2.1": 1612 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 1613 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1614 | set_package_requires(poetry, markers={"foo": "python_version < '3.7'"}) 1615 | 1616 | exporter = Exporter(poetry, NullIO()) 1617 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1618 | 1619 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1620 | content = f.read() 1621 | 1622 | uri = f"{fixture_root_uri}/distributions/demo-0.1.0.tar.gz" 1623 | expected = f"""\ 1624 | foo @ {uri} ; {MARKER_PY27.union(MARKER_PY36_ONLY)} 1625 | """ 1626 | 1627 | assert content == expected 1628 | 1629 | 1630 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1631 | def test_exporter_exports_requirements_txt_with_legacy_packages( 1632 | tmp_path: Path, poetry: Poetry, lock_version: str 1633 | ) -> None: 1634 | poetry.pool.add_repository( 1635 | LegacyRepository( 1636 | "custom", 1637 | "https://example.com/simple", 1638 | ) 1639 | ) 1640 | lock_data: dict[str, Any] = { 1641 | "package": [ 1642 | { 1643 | "name": "foo", 1644 | "version": "1.2.3", 1645 | "optional": False, 1646 | "python-versions": "*", 1647 | }, 1648 | { 1649 | "name": "bar", 1650 | "version": "4.5.6", 1651 | "optional": False, 1652 | "python-versions": "*", 1653 | "source": { 1654 | "type": "legacy", 1655 | "url": "https://example.com/simple", 1656 | "reference": "", 1657 | }, 1658 | }, 1659 | ], 1660 | "metadata": { 1661 | "lock-version": lock_version, 1662 | "python-versions": "*", 1663 | "content-hash": "123456789", 1664 | "files": { 1665 | "foo": [{"name": "foo.whl", "hash": "12345"}], 1666 | "bar": [{"name": "bar.whl", "hash": "67890"}], 1667 | }, 1668 | }, 1669 | } 1670 | fix_lock_data(lock_data) 1671 | if lock_version == "2.1": 1672 | lock_data["package"][1]["groups"] = ["dev"] 1673 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1674 | set_package_requires(poetry, dev={"bar"}) 1675 | 1676 | exporter = Exporter(poetry, NullIO()) 1677 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 1678 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1679 | 1680 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1681 | content = f.read() 1682 | 1683 | expected = f"""\ 1684 | --extra-index-url https://example.com/simple 1685 | 1686 | bar==4.5.6 ; {MARKER_PY} \\ 1687 | --hash=sha256:67890 1688 | foo==1.2.3 ; {MARKER_PY} \\ 1689 | --hash=sha256:12345 1690 | """ 1691 | 1692 | assert content == expected 1693 | 1694 | 1695 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1696 | def test_exporter_exports_requirements_txt_with_url_false( 1697 | tmp_path: Path, poetry: Poetry, lock_version: str 1698 | ) -> None: 1699 | poetry.pool.add_repository( 1700 | LegacyRepository( 1701 | "custom", 1702 | "https://example.com/simple", 1703 | ) 1704 | ) 1705 | lock_data: dict[str, Any] = { 1706 | "package": [ 1707 | { 1708 | "name": "foo", 1709 | "version": "1.2.3", 1710 | "optional": False, 1711 | "python-versions": "*", 1712 | }, 1713 | { 1714 | "name": "bar", 1715 | "version": "4.5.6", 1716 | "optional": False, 1717 | "python-versions": "*", 1718 | "source": { 1719 | "type": "legacy", 1720 | "url": "https://example.com/simple", 1721 | "reference": "", 1722 | }, 1723 | }, 1724 | ], 1725 | "metadata": { 1726 | "lock-version": lock_version, 1727 | "python-versions": "*", 1728 | "content-hash": "123456789", 1729 | "files": { 1730 | "foo": [{"name": "foo.whl", "hash": "12345"}], 1731 | "bar": [{"name": "bar.whl", "hash": "67890"}], 1732 | }, 1733 | }, 1734 | } 1735 | fix_lock_data(lock_data) 1736 | if lock_version == "2.1": 1737 | lock_data["package"][1]["groups"] = ["dev"] 1738 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1739 | set_package_requires(poetry, dev={"bar"}) 1740 | 1741 | exporter = Exporter(poetry, NullIO()) 1742 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 1743 | exporter.with_urls(False) 1744 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1745 | 1746 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1747 | content = f.read() 1748 | 1749 | expected = f"""\ 1750 | bar==4.5.6 ; {MARKER_PY} \\ 1751 | --hash=sha256:67890 1752 | foo==1.2.3 ; {MARKER_PY} \\ 1753 | --hash=sha256:12345 1754 | """ 1755 | 1756 | assert content == expected 1757 | 1758 | 1759 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1760 | def test_exporter_exports_requirements_txt_with_legacy_packages_trusted_host( 1761 | tmp_path: Path, poetry: Poetry, lock_version: str 1762 | ) -> None: 1763 | poetry.pool.add_repository( 1764 | LegacyRepository( 1765 | "custom", 1766 | "http://example.com/simple", 1767 | ) 1768 | ) 1769 | lock_data: dict[str, Any] = { 1770 | "package": [ 1771 | { 1772 | "name": "bar", 1773 | "version": "4.5.6", 1774 | "optional": False, 1775 | "python-versions": "*", 1776 | "source": { 1777 | "type": "legacy", 1778 | "url": "http://example.com/simple", 1779 | "reference": "", 1780 | }, 1781 | }, 1782 | ], 1783 | "metadata": { 1784 | "lock-version": lock_version, 1785 | "python-versions": "*", 1786 | "content-hash": "123456789", 1787 | "files": { 1788 | "bar": [{"name": "bar.whl", "hash": "67890"}], 1789 | }, 1790 | }, 1791 | } 1792 | fix_lock_data(lock_data) 1793 | if lock_version == "2.1": 1794 | lock_data["package"][0]["groups"] = ["dev"] 1795 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1796 | set_package_requires(poetry, dev={"bar"}) 1797 | exporter = Exporter(poetry, NullIO()) 1798 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 1799 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1800 | 1801 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1802 | content = f.read() 1803 | 1804 | expected = f"""\ 1805 | --trusted-host example.com 1806 | --extra-index-url http://example.com/simple 1807 | 1808 | bar==4.5.6 ; {MARKER_PY} \\ 1809 | --hash=sha256:67890 1810 | """ 1811 | 1812 | assert content == expected 1813 | 1814 | 1815 | @pytest.mark.parametrize( 1816 | ["dev", "expected"], 1817 | [ 1818 | ( 1819 | True, 1820 | [ 1821 | f"bar==1.2.2 ; {MARKER_PY}", 1822 | f"baz==1.2.3 ; {MARKER_PY}", 1823 | f"foo==1.2.1 ; {MARKER_PY}", 1824 | ], 1825 | ), 1826 | ( 1827 | False, 1828 | [ 1829 | f"bar==1.2.2 ; {MARKER_PY}", 1830 | f"foo==1.2.1 ; {MARKER_PY}", 1831 | ], 1832 | ), 1833 | ], 1834 | ) 1835 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1836 | def test_exporter_exports_requirements_txt_with_dev_extras( 1837 | tmp_path: Path, poetry: Poetry, dev: bool, expected: list[str], lock_version: str 1838 | ) -> None: 1839 | lock_data: dict[str, Any] = { 1840 | "package": [ 1841 | { 1842 | "name": "foo", 1843 | "version": "1.2.1", 1844 | "optional": False, 1845 | "python-versions": "*", 1846 | }, 1847 | { 1848 | "name": "bar", 1849 | "version": "1.2.2", 1850 | "optional": False, 1851 | "python-versions": "*", 1852 | "dependencies": { 1853 | "baz": { 1854 | "version": ">=0.1.0", 1855 | "optional": True, 1856 | "markers": "extra == 'baz'", 1857 | } 1858 | }, 1859 | "extras": {"baz": ["baz (>=0.1.0)"]}, 1860 | }, 1861 | { 1862 | "name": "baz", 1863 | "version": "1.2.3", 1864 | "optional": False, 1865 | "python-versions": "*", 1866 | }, 1867 | ], 1868 | "metadata": { 1869 | "lock-version": lock_version, 1870 | "python-versions": "*", 1871 | "content-hash": "123456789", 1872 | "files": {"foo": [], "bar": [], "baz": []}, 1873 | }, 1874 | } 1875 | fix_lock_data(lock_data) 1876 | if lock_version == "2.1": 1877 | lock_data["package"][2]["groups"] = ["dev"] 1878 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1879 | set_package_requires(poetry, dev={"baz"}) 1880 | 1881 | exporter = Exporter(poetry, NullIO()) 1882 | if dev: 1883 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 1884 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1885 | 1886 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1887 | content = f.read() 1888 | 1889 | assert content == "\n".join(expected) + "\n" 1890 | 1891 | 1892 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1893 | def test_exporter_exports_requirements_txt_with_legacy_packages_and_duplicate_sources( 1894 | tmp_path: Path, poetry: Poetry, lock_version: str 1895 | ) -> None: 1896 | poetry.pool.add_repository( 1897 | LegacyRepository( 1898 | "custom-example", 1899 | "https://example.com/simple", 1900 | ) 1901 | ) 1902 | poetry.pool.add_repository( 1903 | LegacyRepository( 1904 | "custom-foobaz", 1905 | "https://foobaz.com/simple", 1906 | ) 1907 | ) 1908 | lock_data: dict[str, Any] = { 1909 | "package": [ 1910 | { 1911 | "name": "foo", 1912 | "version": "1.2.3", 1913 | "optional": False, 1914 | "python-versions": "*", 1915 | "source": { 1916 | "type": "legacy", 1917 | "url": "https://example.com/simple", 1918 | "reference": "", 1919 | }, 1920 | }, 1921 | { 1922 | "name": "bar", 1923 | "version": "4.5.6", 1924 | "optional": False, 1925 | "python-versions": "*", 1926 | "source": { 1927 | "type": "legacy", 1928 | "url": "https://example.com/simple", 1929 | "reference": "", 1930 | }, 1931 | }, 1932 | { 1933 | "name": "baz", 1934 | "version": "7.8.9", 1935 | "optional": False, 1936 | "python-versions": "*", 1937 | "source": { 1938 | "type": "legacy", 1939 | "url": "https://foobaz.com/simple", 1940 | "reference": "", 1941 | }, 1942 | }, 1943 | ], 1944 | "metadata": { 1945 | "lock-version": lock_version, 1946 | "python-versions": "*", 1947 | "content-hash": "123456789", 1948 | "files": { 1949 | "foo": [{"name": "foo.whl", "hash": "12345"}], 1950 | "bar": [{"name": "bar.whl", "hash": "67890"}], 1951 | "baz": [{"name": "baz.whl", "hash": "24680"}], 1952 | }, 1953 | }, 1954 | } 1955 | fix_lock_data(lock_data) 1956 | if lock_version == "2.1": 1957 | lock_data["package"][1]["groups"] = ["dev"] 1958 | lock_data["package"][2]["groups"] = ["dev"] 1959 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 1960 | set_package_requires(poetry, dev={"bar", "baz"}) 1961 | 1962 | exporter = Exporter(poetry, NullIO()) 1963 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 1964 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 1965 | 1966 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 1967 | content = f.read() 1968 | 1969 | expected = f"""\ 1970 | --extra-index-url https://example.com/simple 1971 | --extra-index-url https://foobaz.com/simple 1972 | 1973 | bar==4.5.6 ; {MARKER_PY} \\ 1974 | --hash=sha256:67890 1975 | baz==7.8.9 ; {MARKER_PY} \\ 1976 | --hash=sha256:24680 1977 | foo==1.2.3 ; {MARKER_PY} \\ 1978 | --hash=sha256:12345 1979 | """ 1980 | 1981 | assert content == expected 1982 | 1983 | 1984 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 1985 | def test_exporter_exports_requirements_txt_with_two_primary_sources( 1986 | tmp_path: Path, poetry: Poetry, lock_version: str 1987 | ) -> None: 1988 | poetry.pool.remove_repository("PyPI") 1989 | poetry.config.merge( 1990 | { 1991 | "repositories": { 1992 | "custom-a": {"url": "https://a.example.com/simple"}, 1993 | "custom-b": {"url": "https://b.example.com/simple"}, 1994 | }, 1995 | "http-basic": { 1996 | "custom-a": {"username": "foo", "password": "bar"}, 1997 | "custom-b": {"username": "baz", "password": "qux"}, 1998 | }, 1999 | } 2000 | ) 2001 | poetry.pool.add_repository( 2002 | LegacyRepository( 2003 | "custom-b", 2004 | "https://b.example.com/simple", 2005 | config=poetry.config, 2006 | ), 2007 | ) 2008 | poetry.pool.add_repository( 2009 | LegacyRepository( 2010 | "custom-a", 2011 | "https://a.example.com/simple", 2012 | config=poetry.config, 2013 | ), 2014 | ) 2015 | lock_data: dict[str, Any] = { 2016 | "package": [ 2017 | { 2018 | "name": "foo", 2019 | "version": "1.2.3", 2020 | "optional": False, 2021 | "python-versions": "*", 2022 | "source": { 2023 | "type": "legacy", 2024 | "url": "https://a.example.com/simple", 2025 | "reference": "", 2026 | }, 2027 | }, 2028 | { 2029 | "name": "bar", 2030 | "version": "4.5.6", 2031 | "optional": False, 2032 | "python-versions": "*", 2033 | "source": { 2034 | "type": "legacy", 2035 | "url": "https://b.example.com/simple", 2036 | "reference": "", 2037 | }, 2038 | }, 2039 | { 2040 | "name": "baz", 2041 | "version": "7.8.9", 2042 | "optional": False, 2043 | "python-versions": "*", 2044 | "source": { 2045 | "type": "legacy", 2046 | "url": "https://b.example.com/simple", 2047 | "reference": "", 2048 | }, 2049 | }, 2050 | ], 2051 | "metadata": { 2052 | "lock-version": lock_version, 2053 | "python-versions": "*", 2054 | "content-hash": "123456789", 2055 | "files": { 2056 | "foo": [{"name": "foo.whl", "hash": "12345"}], 2057 | "bar": [{"name": "bar.whl", "hash": "67890"}], 2058 | "baz": [{"name": "baz.whl", "hash": "24680"}], 2059 | }, 2060 | }, 2061 | } 2062 | fix_lock_data(lock_data) 2063 | if lock_version == "2.1": 2064 | lock_data["package"][1]["groups"] = ["dev"] 2065 | lock_data["package"][2]["groups"] = ["dev"] 2066 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2067 | set_package_requires(poetry, dev={"bar", "baz"}) 2068 | 2069 | exporter = Exporter(poetry, NullIO()) 2070 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 2071 | exporter.with_credentials() 2072 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 2073 | 2074 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 2075 | content = f.read() 2076 | 2077 | expected = f"""\ 2078 | --index-url https://baz:qux@b.example.com/simple 2079 | --extra-index-url https://foo:bar@a.example.com/simple 2080 | 2081 | bar==4.5.6 ; {MARKER_PY} \\ 2082 | --hash=sha256:67890 2083 | baz==7.8.9 ; {MARKER_PY} \\ 2084 | --hash=sha256:24680 2085 | foo==1.2.3 ; {MARKER_PY} \\ 2086 | --hash=sha256:12345 2087 | """ 2088 | 2089 | assert content == expected 2090 | 2091 | 2092 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2093 | def test_exporter_exports_requirements_txt_with_legacy_packages_and_credentials( 2094 | tmp_path: Path, poetry: Poetry, config: Config, lock_version: str 2095 | ) -> None: 2096 | poetry.config.merge( 2097 | { 2098 | "repositories": {"custom": {"url": "https://example.com/simple"}}, 2099 | "http-basic": {"custom": {"username": "foo", "password": "bar"}}, 2100 | } 2101 | ) 2102 | poetry.pool.add_repository( 2103 | LegacyRepository("custom", "https://example.com/simple", config=poetry.config) 2104 | ) 2105 | lock_data: dict[str, Any] = { 2106 | "package": [ 2107 | { 2108 | "name": "foo", 2109 | "version": "1.2.3", 2110 | "optional": False, 2111 | "python-versions": "*", 2112 | }, 2113 | { 2114 | "name": "bar", 2115 | "version": "4.5.6", 2116 | "optional": False, 2117 | "python-versions": "*", 2118 | "source": { 2119 | "type": "legacy", 2120 | "url": "https://example.com/simple", 2121 | "reference": "", 2122 | }, 2123 | }, 2124 | ], 2125 | "metadata": { 2126 | "lock-version": lock_version, 2127 | "python-versions": "*", 2128 | "content-hash": "123456789", 2129 | "files": { 2130 | "foo": [{"name": "foo.whl", "hash": "12345"}], 2131 | "bar": [{"name": "bar.whl", "hash": "67890"}], 2132 | }, 2133 | }, 2134 | } 2135 | fix_lock_data(lock_data) 2136 | if lock_version == "2.1": 2137 | lock_data["package"][1]["groups"] = ["dev"] 2138 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2139 | set_package_requires(poetry, dev={"bar"}) 2140 | 2141 | exporter = Exporter(poetry, NullIO()) 2142 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 2143 | exporter.with_credentials() 2144 | exporter.export( 2145 | "requirements.txt", 2146 | tmp_path, 2147 | "requirements.txt", 2148 | ) 2149 | 2150 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 2151 | content = f.read() 2152 | 2153 | expected = f"""\ 2154 | --extra-index-url https://foo:bar@example.com/simple 2155 | 2156 | bar==4.5.6 ; {MARKER_PY} \\ 2157 | --hash=sha256:67890 2158 | foo==1.2.3 ; {MARKER_PY} \\ 2159 | --hash=sha256:12345 2160 | """ 2161 | 2162 | assert content == expected 2163 | 2164 | 2165 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2166 | def test_exporter_exports_requirements_txt_to_standard_output( 2167 | tmp_path: Path, poetry: Poetry, lock_version: str 2168 | ) -> None: 2169 | lock_data = { 2170 | "package": [ 2171 | { 2172 | "name": "foo", 2173 | "version": "1.2.3", 2174 | "optional": False, 2175 | "python-versions": "*", 2176 | }, 2177 | { 2178 | "name": "bar", 2179 | "version": "4.5.6", 2180 | "optional": False, 2181 | "python-versions": "*", 2182 | }, 2183 | ], 2184 | "metadata": { 2185 | "lock-version": lock_version, 2186 | "python-versions": "*", 2187 | "content-hash": "123456789", 2188 | "files": {"foo": [], "bar": []}, 2189 | }, 2190 | } 2191 | fix_lock_data(lock_data) 2192 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2193 | set_package_requires(poetry) 2194 | 2195 | exporter = Exporter(poetry, NullIO()) 2196 | io = BufferedIO() 2197 | exporter.export("requirements.txt", tmp_path, io) 2198 | 2199 | expected = f"""\ 2200 | bar==4.5.6 ; {MARKER_PY} 2201 | foo==1.2.3 ; {MARKER_PY} 2202 | """ 2203 | 2204 | assert io.fetch_output() == expected 2205 | 2206 | 2207 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2208 | def test_exporter_doesnt_confuse_repeated_packages( 2209 | tmp_path: Path, poetry: Poetry, lock_version: str 2210 | ) -> None: 2211 | # Testcase derived from . 2212 | lock_data: dict[str, Any] = { 2213 | "package": [ 2214 | { 2215 | "name": "celery", 2216 | "version": "5.1.2", 2217 | "optional": False, 2218 | "python-versions": "<3.7", 2219 | "dependencies": { 2220 | "click": ">=7.0,<8.0", 2221 | "click-didyoumean": ">=0.0.3", 2222 | "click-plugins": ">=1.1.1", 2223 | }, 2224 | }, 2225 | { 2226 | "name": "celery", 2227 | "version": "5.2.3", 2228 | "optional": False, 2229 | "python-versions": ">=3.7", 2230 | "dependencies": { 2231 | "click": ">=8.0.3,<9.0", 2232 | "click-didyoumean": ">=0.0.3", 2233 | "click-plugins": ">=1.1.1", 2234 | }, 2235 | }, 2236 | { 2237 | "name": "click", 2238 | "version": "7.1.2", 2239 | "optional": False, 2240 | "python-versions": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", 2241 | }, 2242 | { 2243 | "name": "click", 2244 | "version": "8.0.3", 2245 | "optional": False, 2246 | "python-versions": ">=3.6", 2247 | "dependencies": {}, 2248 | }, 2249 | { 2250 | "name": "click-didyoumean", 2251 | "version": "0.0.3", 2252 | "optional": False, 2253 | "python-versions": "*", 2254 | "dependencies": {"click": "*"}, 2255 | }, 2256 | { 2257 | "name": "click-didyoumean", 2258 | "version": "0.3.0", 2259 | "optional": False, 2260 | "python-versions": ">=3.6.2,<4.0.0", 2261 | "dependencies": {"click": ">=7"}, 2262 | }, 2263 | { 2264 | "name": "click-plugins", 2265 | "version": "1.1.1", 2266 | "optional": False, 2267 | "python-versions": "*", 2268 | "dependencies": {"click": ">=4.0"}, 2269 | }, 2270 | ], 2271 | "metadata": { 2272 | "lock-version": lock_version, 2273 | "python-versions": "^3.6", 2274 | "content-hash": ( 2275 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 2276 | ), 2277 | "files": { 2278 | "celery": [], 2279 | "click-didyoumean": [], 2280 | "click-plugins": [], 2281 | "click": [], 2282 | }, 2283 | }, 2284 | } 2285 | fix_lock_data(lock_data) 2286 | if lock_version == "2.1": 2287 | lock_data["package"][0]["markers"] = "python_version < '3.7'" 2288 | lock_data["package"][1]["markers"] = "python_version >= '3.7'" 2289 | lock_data["package"][2]["markers"] = "python_version < '3.7'" 2290 | lock_data["package"][3]["markers"] = "python_version >= '3.7'" 2291 | lock_data["package"][4]["markers"] = "python_full_version < '3.6.2'" 2292 | lock_data["package"][5]["markers"] = "python_full_version >= '3.6.2'" 2293 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2294 | root = poetry.package.with_dependency_groups([], only=True) 2295 | root.python_versions = "^3.6" 2296 | root.add_dependency( 2297 | Factory.create_dependency( 2298 | name="celery", constraint={"version": "5.1.2", "python": "<3.7"} 2299 | ) 2300 | ) 2301 | root.add_dependency( 2302 | Factory.create_dependency( 2303 | name="celery", constraint={"version": "5.2.3", "python": ">=3.7"} 2304 | ) 2305 | ) 2306 | poetry._package = root 2307 | 2308 | exporter = Exporter(poetry, NullIO()) 2309 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 2310 | io = BufferedIO() 2311 | exporter.export("requirements.txt", tmp_path, io) 2312 | 2313 | expected = f"""\ 2314 | celery==5.1.2 ; {MARKER_PY36_ONLY} 2315 | celery==5.2.3 ; {MARKER_PY37} 2316 | click-didyoumean==0.0.3 ; {MARKER_PY36_PY362 if lock_version == "2.1" else MARKER_PY36_PY362_ALT} 2317 | click-didyoumean==0.3.0 ; {MARKER_PY362_PY40} 2318 | click-plugins==1.1.1 ; {MARKER_PY36} 2319 | click==7.1.2 ; {MARKER_PY36_ONLY} 2320 | click==8.0.3 ; {MARKER_PY37} 2321 | """ 2322 | 2323 | assert io.fetch_output() == expected 2324 | 2325 | 2326 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2327 | def test_exporter_handles_extras_next_to_non_extras( 2328 | tmp_path: Path, poetry: Poetry, lock_version: str 2329 | ) -> None: 2330 | # Testcase similar to the solver testcase added at #5305. 2331 | lock_data = { 2332 | "package": [ 2333 | { 2334 | "name": "localstack", 2335 | "python-versions": "*", 2336 | "version": "1.0.0", 2337 | "optional": False, 2338 | "dependencies": { 2339 | "localstack-ext": [ 2340 | {"version": ">=1.0.0"}, 2341 | { 2342 | "version": ">=1.0.0", 2343 | "extras": ["bar"], 2344 | "markers": 'extra == "foo"', 2345 | }, 2346 | ] 2347 | }, 2348 | "extras": {"foo": ["localstack-ext[bar] (>=1.0.0)"]}, 2349 | }, 2350 | { 2351 | "name": "localstack-ext", 2352 | "python-versions": "*", 2353 | "version": "1.0.0", 2354 | "optional": False, 2355 | "dependencies": { 2356 | "something": "*", 2357 | "something-else": { 2358 | "version": ">=1.0.0", 2359 | "markers": 'extra == "bar"', 2360 | }, 2361 | "another-thing": { 2362 | "version": ">=1.0.0", 2363 | "markers": 'extra == "baz"', 2364 | }, 2365 | }, 2366 | "extras": { 2367 | "bar": ["something-else (>=1.0.0)"], 2368 | "baz": ["another-thing (>=1.0.0)"], 2369 | }, 2370 | }, 2371 | { 2372 | "name": "something", 2373 | "python-versions": "*", 2374 | "version": "1.0.0", 2375 | "optional": False, 2376 | "dependencies": {}, 2377 | }, 2378 | { 2379 | "name": "something-else", 2380 | "python-versions": "*", 2381 | "version": "1.0.0", 2382 | "optional": False, 2383 | "dependencies": {}, 2384 | }, 2385 | ], 2386 | "metadata": { 2387 | "lock-version": lock_version, 2388 | "python-versions": "^3.6", 2389 | "content-hash": ( 2390 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 2391 | ), 2392 | "files": { 2393 | "localstack": [], 2394 | "localstack-ext": [], 2395 | "something": [], 2396 | "something-else": [], 2397 | "another-thing": [], 2398 | }, 2399 | }, 2400 | } 2401 | fix_lock_data(lock_data) 2402 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2403 | root = poetry.package.with_dependency_groups([], only=True) 2404 | root.python_versions = "^3.6" 2405 | root.add_dependency( 2406 | Factory.create_dependency( 2407 | name="localstack", constraint={"version": "^1.0.0", "extras": ["foo"]} 2408 | ) 2409 | ) 2410 | poetry._package = root 2411 | 2412 | exporter = Exporter(poetry, NullIO()) 2413 | io = BufferedIO() 2414 | exporter.export("requirements.txt", tmp_path, io) 2415 | 2416 | # It does not matter whether packages are exported with extras or not 2417 | # because all dependencies are listed explicitly. 2418 | if lock_version == "1.1": 2419 | expected = f"""\ 2420 | localstack-ext==1.0.0 ; {MARKER_PY36} 2421 | localstack-ext[bar]==1.0.0 ; {MARKER_PY36} 2422 | localstack[foo]==1.0.0 ; {MARKER_PY36} 2423 | something-else==1.0.0 ; {MARKER_PY36} 2424 | something==1.0.0 ; {MARKER_PY36} 2425 | """ 2426 | else: 2427 | expected = f"""\ 2428 | localstack-ext==1.0.0 ; {MARKER_PY36} 2429 | localstack==1.0.0 ; {MARKER_PY36} 2430 | something-else==1.0.0 ; {MARKER_PY36} 2431 | something==1.0.0 ; {MARKER_PY36} 2432 | """ 2433 | 2434 | assert io.fetch_output() == expected 2435 | 2436 | 2437 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2438 | def test_exporter_handles_overlapping_python_versions( 2439 | tmp_path: Path, poetry: Poetry, lock_version: str 2440 | ) -> None: 2441 | # Testcase derived from 2442 | # https://github.com/python-poetry/poetry-plugin-export/issues/32. 2443 | lock_data: dict[str, Any] = { 2444 | "package": [ 2445 | { 2446 | "name": "ipython", 2447 | "python-versions": ">=3.6", 2448 | "version": "7.16.3", 2449 | "optional": False, 2450 | "dependencies": {}, 2451 | }, 2452 | { 2453 | "name": "ipython", 2454 | "python-versions": ">=3.7", 2455 | "version": "7.34.0", 2456 | "optional": False, 2457 | "dependencies": {}, 2458 | }, 2459 | { 2460 | "name": "slash", 2461 | "python-versions": ">=3.6.*", 2462 | "version": "1.13.0", 2463 | "optional": False, 2464 | "dependencies": { 2465 | "ipython": [ 2466 | { 2467 | "version": "*", 2468 | "markers": ( 2469 | 'python_version >= "3.6" and implementation_name !=' 2470 | ' "pypy"' 2471 | ), 2472 | }, 2473 | { 2474 | "version": "<7.17.0", 2475 | "markers": ( 2476 | 'python_version < "3.6" and implementation_name !=' 2477 | ' "pypy"' 2478 | ), 2479 | }, 2480 | ], 2481 | }, 2482 | }, 2483 | ], 2484 | "metadata": { 2485 | "lock-version": lock_version, 2486 | "python-versions": "^3.6", 2487 | "content-hash": ( 2488 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 2489 | ), 2490 | "files": { 2491 | "ipython": [], 2492 | "slash": [], 2493 | }, 2494 | }, 2495 | } 2496 | fix_lock_data(lock_data) 2497 | if lock_version == "2.1": 2498 | lock_data["package"][0]["markers"] = ( 2499 | "python_version >= '3.6' and python_version < '3.7'" 2500 | ) 2501 | lock_data["package"][1]["markers"] = "python_version >= '3.7'" 2502 | lock_data["package"][2]["markers"] = "implementation_name == 'cpython'" 2503 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2504 | root = poetry.package.with_dependency_groups([], only=True) 2505 | root.python_versions = "^3.6" 2506 | root.add_dependency( 2507 | Factory.create_dependency( 2508 | name="ipython", 2509 | constraint={"version": "*", "python": "~3.6"}, 2510 | ) 2511 | ) 2512 | root.add_dependency( 2513 | Factory.create_dependency( 2514 | name="ipython", 2515 | constraint={"version": "^7.17", "python": "^3.7"}, 2516 | ) 2517 | ) 2518 | root.add_dependency( 2519 | Factory.create_dependency( 2520 | name="slash", 2521 | constraint={ 2522 | "version": "^1.12", 2523 | "markers": "implementation_name == 'cpython'", 2524 | }, 2525 | ) 2526 | ) 2527 | poetry._package = root 2528 | 2529 | exporter = Exporter(poetry, NullIO()) 2530 | io = BufferedIO() 2531 | exporter.export("requirements.txt", tmp_path, io) 2532 | 2533 | expected = f"""\ 2534 | ipython==7.16.3 ; {MARKER_PY36_ONLY} 2535 | ipython==7.34.0 ; {MARKER_PY37} 2536 | slash==1.13.0 ; {MARKER_PY36} and {MARKER_CPYTHON} 2537 | """ 2538 | 2539 | assert io.fetch_output() == expected 2540 | 2541 | 2542 | @pytest.mark.parametrize( 2543 | ["with_extras", "expected"], 2544 | [ 2545 | ( 2546 | True, 2547 | [f"foo[test]==1.0.0 ; {MARKER_PY36}", f"pytest==6.24.0 ; {MARKER_PY36}"], 2548 | ), 2549 | ( 2550 | False, 2551 | [f"foo==1.0.0 ; {MARKER_PY36}"], 2552 | ), 2553 | ], 2554 | ) 2555 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2556 | def test_exporter_omits_unwanted_extras( 2557 | tmp_path: Path, 2558 | poetry: Poetry, 2559 | with_extras: bool, 2560 | expected: list[str], 2561 | lock_version: str, 2562 | ) -> None: 2563 | # Testcase derived from 2564 | # https://github.com/python-poetry/poetry/issues/5779 2565 | lock_data: dict[str, Any] = { 2566 | "package": [ 2567 | { 2568 | "name": "foo", 2569 | "python-versions": ">=3.6", 2570 | "version": "1.0.0", 2571 | "optional": False, 2572 | "dependencies": {"pytest": {"version": "^6.2.4", "optional": True}}, 2573 | "extras": {"test": ["pytest (>=6.2.4,<7.0.0)"]}, 2574 | }, 2575 | { 2576 | "name": "pytest", 2577 | "python-versions": ">=3.6", 2578 | "version": "6.24.0", 2579 | "optional": False, 2580 | "dependencies": {}, 2581 | }, 2582 | ], 2583 | "metadata": { 2584 | "lock-version": lock_version, 2585 | "python-versions": "^3.6", 2586 | "content-hash": ( 2587 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 2588 | ), 2589 | "files": { 2590 | "foo": [], 2591 | "pytest": [], 2592 | }, 2593 | }, 2594 | } 2595 | fix_lock_data(lock_data) 2596 | if lock_version == "2.1": 2597 | lock_data["package"][0]["groups"] = ["main", "with-extras"] 2598 | lock_data["package"][1]["groups"] = ["with-extras"] 2599 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2600 | root = poetry.package.with_dependency_groups([], only=True) 2601 | root.python_versions = "^3.6" 2602 | root.add_dependency( 2603 | Factory.create_dependency( 2604 | name="foo", 2605 | constraint={"version": "*"}, 2606 | ) 2607 | ) 2608 | root.add_dependency( 2609 | Factory.create_dependency( 2610 | name="foo", 2611 | constraint={"version": "*", "extras": ["test"]}, 2612 | groups=["with-extras"], 2613 | ) 2614 | ) 2615 | poetry._package = root 2616 | 2617 | io = BufferedIO() 2618 | exporter = Exporter(poetry, NullIO()) 2619 | if with_extras: 2620 | exporter.only_groups([canonicalize_name("with-extras")]) 2621 | # It does not matter whether packages are exported with extras or not 2622 | # because all dependencies are listed explicitly. 2623 | if lock_version == "2.1": 2624 | expected = [req.replace("foo[test]", "foo") for req in expected] 2625 | exporter.export("requirements.txt", tmp_path, io) 2626 | 2627 | assert io.fetch_output() == "\n".join(expected) + "\n" 2628 | 2629 | 2630 | @pytest.mark.parametrize( 2631 | ["fmt", "expected"], 2632 | [ 2633 | ( 2634 | "constraints.txt", 2635 | [ 2636 | f"bar==4.5.6 ; {MARKER_PY}", 2637 | f"baz==7.8.9 ; {MARKER_PY}", 2638 | f"foo==1.2.3 ; {MARKER_PY}", 2639 | ], 2640 | ), 2641 | ( 2642 | "requirements.txt", 2643 | [ 2644 | f"bar==4.5.6 ; {MARKER_PY}", 2645 | f"bar[baz]==4.5.6 ; {MARKER_PY}", 2646 | f"baz==7.8.9 ; {MARKER_PY}", 2647 | f"foo==1.2.3 ; {MARKER_PY}", 2648 | ], 2649 | ), 2650 | ], 2651 | ) 2652 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2653 | def test_exporter_omits_and_includes_extras_for_txt_formats( 2654 | tmp_path: Path, poetry: Poetry, fmt: str, expected: list[str], lock_version: str 2655 | ) -> None: 2656 | lock_data = { 2657 | "package": [ 2658 | { 2659 | "name": "foo", 2660 | "version": "1.2.3", 2661 | "optional": False, 2662 | "python-versions": "*", 2663 | "dependencies": { 2664 | "bar": { 2665 | "extras": ["baz"], 2666 | "version": ">=0.1.0", 2667 | } 2668 | }, 2669 | }, 2670 | { 2671 | "name": "bar", 2672 | "version": "4.5.6", 2673 | "optional": False, 2674 | "python-versions": "*", 2675 | "dependencies": { 2676 | "baz": { 2677 | "version": ">=0.1.0", 2678 | "optional": True, 2679 | "markers": "extra == 'baz'", 2680 | } 2681 | }, 2682 | "extras": {"baz": ["baz (>=0.1.0)"]}, 2683 | }, 2684 | { 2685 | "name": "baz", 2686 | "version": "7.8.9", 2687 | "optional": False, 2688 | "python-versions": "*", 2689 | }, 2690 | ], 2691 | "metadata": { 2692 | "lock-version": lock_version, 2693 | "python-versions": "*", 2694 | "content-hash": "123456789", 2695 | "files": {"foo": [], "bar": [], "baz": []}, 2696 | }, 2697 | } 2698 | fix_lock_data(lock_data) 2699 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2700 | set_package_requires(poetry) 2701 | 2702 | exporter = Exporter(poetry, NullIO()) 2703 | exporter.export(fmt, tmp_path, "exported.txt") 2704 | 2705 | with (tmp_path / "exported.txt").open(encoding="utf-8") as f: 2706 | content = f.read() 2707 | 2708 | # It does not matter whether packages are exported with extras or not 2709 | # because all dependencies are listed explicitly. 2710 | if lock_version == "2.1": 2711 | expected = [req for req in expected if not req.startswith("bar[baz]")] 2712 | assert content == "\n".join(expected) + "\n" 2713 | 2714 | 2715 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2716 | def test_exporter_prints_warning_for_constraints_txt_with_editable_packages( 2717 | tmp_path: Path, poetry: Poetry, lock_version: str 2718 | ) -> None: 2719 | lock_data = { 2720 | "package": [ 2721 | { 2722 | "name": "foo", 2723 | "version": "1.2.3", 2724 | "optional": False, 2725 | "python-versions": "*", 2726 | "source": { 2727 | "type": "git", 2728 | "url": "https://github.com/foo/foo.git", 2729 | "reference": "123456", 2730 | }, 2731 | "develop": True, 2732 | }, 2733 | { 2734 | "name": "bar", 2735 | "version": "7.8.9", 2736 | "optional": False, 2737 | "python-versions": "*", 2738 | }, 2739 | { 2740 | "name": "baz", 2741 | "version": "4.5.6", 2742 | "optional": False, 2743 | "python-versions": "*", 2744 | "source": { 2745 | "type": "directory", 2746 | "url": "sample_project", 2747 | "reference": "", 2748 | }, 2749 | "develop": True, 2750 | }, 2751 | ], 2752 | "metadata": { 2753 | "lock-version": lock_version, 2754 | "python-versions": "*", 2755 | "content-hash": "123456789", 2756 | "files": {"foo": [], "bar": [], "baz": []}, 2757 | }, 2758 | } 2759 | fix_lock_data(lock_data) 2760 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2761 | set_package_requires(poetry) 2762 | 2763 | io = BufferedIO() 2764 | exporter = Exporter(poetry, io) 2765 | exporter.export("constraints.txt", tmp_path, "constraints.txt") 2766 | 2767 | expected_error_out = ( 2768 | "Warning: foo is locked in develop (editable) mode, which is " 2769 | "incompatible with the constraints.txt format.\n" 2770 | "Warning: baz is locked in develop (editable) mode, which is " 2771 | "incompatible with the constraints.txt format.\n" 2772 | ) 2773 | 2774 | assert io.fetch_error() == expected_error_out 2775 | 2776 | with (tmp_path / "constraints.txt").open(encoding="utf-8") as f: 2777 | content = f.read() 2778 | 2779 | assert content == f"bar==7.8.9 ; {MARKER_PY}\n" 2780 | 2781 | 2782 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2783 | def test_exporter_respects_package_sources( 2784 | tmp_path: Path, poetry: Poetry, lock_version: str 2785 | ) -> None: 2786 | lock_data: dict[str, Any] = { 2787 | "package": [ 2788 | { 2789 | "name": "foo", 2790 | "python-versions": ">=3.6", 2791 | "version": "1.0.0", 2792 | "optional": False, 2793 | "dependencies": {}, 2794 | "source": { 2795 | "type": "url", 2796 | "url": "https://example.com/foo-darwin.whl", 2797 | }, 2798 | }, 2799 | { 2800 | "name": "foo", 2801 | "python-versions": ">=3.6", 2802 | "version": "1.0.0", 2803 | "optional": False, 2804 | "dependencies": {}, 2805 | "source": { 2806 | "type": "url", 2807 | "url": "https://example.com/foo-linux.whl", 2808 | }, 2809 | }, 2810 | ], 2811 | "metadata": { 2812 | "lock-version": lock_version, 2813 | "python-versions": "^3.6", 2814 | "content-hash": ( 2815 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 2816 | ), 2817 | "files": { 2818 | "foo": [], 2819 | }, 2820 | }, 2821 | } 2822 | fix_lock_data(lock_data) 2823 | if lock_version == "2.1": 2824 | lock_data["package"][0]["markers"] = "sys_platform == 'darwin'" 2825 | lock_data["package"][1]["markers"] = "sys_platform == 'linux'" 2826 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2827 | root = poetry.package.with_dependency_groups([], only=True) 2828 | root.python_versions = "^3.6" 2829 | root.add_dependency( 2830 | Factory.create_dependency( 2831 | name="foo", 2832 | constraint={ 2833 | "url": "https://example.com/foo-linux.whl", 2834 | "platform": "linux", 2835 | }, 2836 | ) 2837 | ) 2838 | root.add_dependency( 2839 | Factory.create_dependency( 2840 | name="foo", 2841 | constraint={ 2842 | "url": "https://example.com/foo-darwin.whl", 2843 | "platform": "darwin", 2844 | }, 2845 | ) 2846 | ) 2847 | poetry._package = root 2848 | 2849 | io = BufferedIO() 2850 | exporter = Exporter(poetry, NullIO()) 2851 | exporter.export("requirements.txt", tmp_path, io) 2852 | 2853 | expected = f"""\ 2854 | foo @ https://example.com/foo-darwin.whl ; {MARKER_PY36} and {MARKER_DARWIN} 2855 | foo @ https://example.com/foo-linux.whl ; {MARKER_PY36} and {MARKER_LINUX} 2856 | """ 2857 | 2858 | assert io.fetch_output() == expected 2859 | 2860 | 2861 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2862 | def test_exporter_tolerates_non_existent_extra( 2863 | tmp_path: Path, poetry: Poetry, lock_version: str 2864 | ) -> None: 2865 | # foo actually has a 'bar' extra, but pyproject.toml mistakenly references a 'baz' 2866 | # extra. 2867 | lock_data = { 2868 | "package": [ 2869 | { 2870 | "name": "foo", 2871 | "version": "1.2.3", 2872 | "optional": False, 2873 | "python-versions": "*", 2874 | "dependencies": { 2875 | "bar": { 2876 | "version": ">=0.1.0", 2877 | "optional": True, 2878 | "markers": "extra == 'bar'", 2879 | } 2880 | }, 2881 | "extras": {"bar": ["bar (>=0.1.0)"]}, 2882 | }, 2883 | ], 2884 | "metadata": { 2885 | "lock-version": lock_version, 2886 | "python-versions": "*", 2887 | "content-hash": "123456789", 2888 | "files": {"foo": [], "bar": []}, 2889 | }, 2890 | } 2891 | fix_lock_data(lock_data) 2892 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2893 | root = poetry.package.with_dependency_groups([], only=True) 2894 | root.add_dependency( 2895 | Factory.create_dependency( 2896 | name="foo", constraint={"version": "^1.2", "extras": ["baz"]} 2897 | ) 2898 | ) 2899 | poetry._package = root 2900 | 2901 | exporter = Exporter(poetry, NullIO()) 2902 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 2903 | 2904 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 2905 | content = f.read() 2906 | 2907 | if lock_version == "1.1": 2908 | expected = f"""\ 2909 | foo[baz]==1.2.3 ; {MARKER_PY27} or {MARKER_PY36} 2910 | """ 2911 | else: 2912 | expected = f"""\ 2913 | foo==1.2.3 ; {MARKER_PY27} or {MARKER_PY36} 2914 | """ 2915 | assert content == expected 2916 | 2917 | 2918 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 2919 | def test_exporter_exports_extra_index_url_and_trusted_host( 2920 | tmp_path: Path, poetry: Poetry, lock_version: str 2921 | ) -> None: 2922 | poetry.pool.add_repository( 2923 | LegacyRepository( 2924 | "custom", 2925 | "http://example.com/simple", 2926 | ), 2927 | priority=Priority.EXPLICIT, 2928 | ) 2929 | lock_data = { 2930 | "package": [ 2931 | { 2932 | "name": "foo", 2933 | "version": "1.2.3", 2934 | "optional": False, 2935 | "python-versions": "*", 2936 | "dependencies": {"bar": "*"}, 2937 | }, 2938 | { 2939 | "name": "bar", 2940 | "version": "4.5.6", 2941 | "optional": False, 2942 | "python-versions": "*", 2943 | "source": { 2944 | "type": "legacy", 2945 | "url": "http://example.com/simple", 2946 | "reference": "", 2947 | }, 2948 | }, 2949 | ], 2950 | "metadata": { 2951 | "lock-version": lock_version, 2952 | "python-versions": "*", 2953 | "content-hash": "123456789", 2954 | "files": {"foo": [], "bar": []}, 2955 | }, 2956 | } 2957 | fix_lock_data(lock_data) 2958 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 2959 | set_package_requires(poetry) 2960 | 2961 | exporter = Exporter(poetry, NullIO()) 2962 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 2963 | 2964 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 2965 | content = f.read() 2966 | 2967 | expected = f"""\ 2968 | --trusted-host example.com 2969 | --extra-index-url http://example.com/simple 2970 | 2971 | bar==4.5.6 ; {MARKER_PY} 2972 | foo==1.2.3 ; {MARKER_PY} 2973 | """ 2974 | assert content == expected 2975 | 2976 | 2977 | @pytest.mark.parametrize("lock_version", ("2.0", "2.1")) 2978 | def test_exporter_not_confused_by_extras_in_sub_dependencies( 2979 | tmp_path: Path, poetry: Poetry, lock_version: str 2980 | ) -> None: 2981 | # Testcase derived from 2982 | # https://github.com/python-poetry/poetry-plugin-export/issues/208 2983 | lock_data: dict[str, Any] = { 2984 | "package": [ 2985 | { 2986 | "name": "typer", 2987 | "python-versions": ">=3.6", 2988 | "version": "0.9.0", 2989 | "optional": False, 2990 | "files": [], 2991 | "dependencies": { 2992 | "click": ">=7.1.1,<9.0.0", 2993 | "colorama": { 2994 | "version": ">=0.4.3,<0.5.0", 2995 | "optional": True, 2996 | "markers": 'extra == "all"', 2997 | }, 2998 | }, 2999 | "extras": {"all": ["colorama (>=0.4.3,<0.5.0)"]}, 3000 | }, 3001 | { 3002 | "name": "click", 3003 | "python-versions": ">=3.7", 3004 | "version": "8.1.3", 3005 | "optional": False, 3006 | "files": [], 3007 | "dependencies": { 3008 | "colorama": { 3009 | "version": "*", 3010 | "markers": 'platform_system == "Windows"', 3011 | } 3012 | }, 3013 | }, 3014 | { 3015 | "name": "colorama", 3016 | "python-versions": ">=3.7", 3017 | "version": "0.4.6", 3018 | "optional": False, 3019 | "files": [], 3020 | }, 3021 | ], 3022 | "metadata": { 3023 | "lock-version": lock_version, 3024 | "python-versions": "^3.11", 3025 | "content-hash": ( 3026 | "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" 3027 | ), 3028 | }, 3029 | } 3030 | if lock_version == "2.1": 3031 | for locked_package in lock_data["package"]: 3032 | locked_package["groups"] = ["main"] 3033 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 3034 | root = poetry.package.with_dependency_groups([], only=True) 3035 | root.python_versions = "^3.11" 3036 | root.add_dependency( 3037 | Factory.create_dependency( 3038 | name="typer", 3039 | constraint={"version": "^0.9.0", "extras": ["all"]}, 3040 | ) 3041 | ) 3042 | poetry._package = root 3043 | 3044 | io = BufferedIO() 3045 | exporter = Exporter(poetry, NullIO()) 3046 | exporter.export("requirements.txt", tmp_path, io) 3047 | 3048 | if lock_version == "2.0": 3049 | expected = """\ 3050 | click==8.1.3 ; python_version >= "3.11" and python_version < "4.0" 3051 | colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" 3052 | typer[all]==0.9.0 ; python_version >= "3.11" and python_version < "4.0" 3053 | """ 3054 | else: 3055 | expected = """\ 3056 | click==8.1.3 ; python_version >= "3.11" and python_version < "4.0" 3057 | colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" 3058 | typer==0.9.0 ; python_version >= "3.11" and python_version < "4.0" 3059 | """ 3060 | assert io.fetch_output() == expected 3061 | 3062 | 3063 | @pytest.mark.parametrize( 3064 | ("priorities", "expected"), 3065 | [ 3066 | ([("custom-a", Priority.PRIMARY), ("custom-b", Priority.PRIMARY)], ("a", "b")), 3067 | ([("custom-b", Priority.PRIMARY), ("custom-a", Priority.PRIMARY)], ("b", "a")), 3068 | ( 3069 | [("custom-b", Priority.SUPPLEMENTAL), ("custom-a", Priority.PRIMARY)], 3070 | ("a", "b"), 3071 | ), 3072 | ([("custom-b", Priority.EXPLICIT), ("custom-a", Priority.PRIMARY)], ("a", "b")), 3073 | ( 3074 | [ 3075 | ("PyPI", Priority.PRIMARY), 3076 | ("custom-a", Priority.PRIMARY), 3077 | ("custom-b", Priority.PRIMARY), 3078 | ], 3079 | ("", "a", "b"), 3080 | ), 3081 | ( 3082 | [ 3083 | ("PyPI", Priority.EXPLICIT), 3084 | ("custom-a", Priority.PRIMARY), 3085 | ("custom-b", Priority.PRIMARY), 3086 | ], 3087 | ("", "a", "b"), 3088 | ), 3089 | ( 3090 | [ 3091 | ("custom-a", Priority.PRIMARY), 3092 | ("custom-b", Priority.PRIMARY), 3093 | ("PyPI", Priority.SUPPLEMENTAL), 3094 | ], 3095 | ("", "a", "b"), 3096 | ), 3097 | ], 3098 | ) 3099 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 3100 | def test_exporter_index_urls( 3101 | tmp_path: Path, 3102 | poetry: Poetry, 3103 | priorities: list[tuple[str, Priority]], 3104 | expected: tuple[str, ...], 3105 | lock_version: str, 3106 | ) -> None: 3107 | pypi = poetry.pool.repository("PyPI") 3108 | poetry.pool.remove_repository("PyPI") 3109 | for name, prio in priorities: 3110 | if name.lower() == "pypi": 3111 | repo = pypi 3112 | else: 3113 | repo = LegacyRepository(name, f"https://{name[-1]}.example.com/simple") 3114 | poetry.pool.add_repository(repo, priority=prio) 3115 | 3116 | lock_data: dict[str, Any] = { 3117 | "package": [ 3118 | { 3119 | "name": "foo", 3120 | "version": "1.2.3", 3121 | "optional": False, 3122 | "python-versions": "*", 3123 | "source": { 3124 | "type": "legacy", 3125 | "url": "https://a.example.com/simple", 3126 | "reference": "", 3127 | }, 3128 | }, 3129 | { 3130 | "name": "bar", 3131 | "version": "4.5.6", 3132 | "optional": False, 3133 | "python-versions": "*", 3134 | "source": { 3135 | "type": "legacy", 3136 | "url": "https://b.example.com/simple", 3137 | "reference": "", 3138 | }, 3139 | }, 3140 | ], 3141 | "metadata": { 3142 | "lock-version": lock_version, 3143 | "python-versions": "*", 3144 | "content-hash": "123456789", 3145 | "files": { 3146 | "foo": [{"name": "foo.whl", "hash": "12345"}], 3147 | "bar": [{"name": "bar.whl", "hash": "67890"}], 3148 | }, 3149 | }, 3150 | } 3151 | fix_lock_data(lock_data) 3152 | if lock_version == "2.1": 3153 | lock_data["package"][0]["groups"] = ["dev"] 3154 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 3155 | set_package_requires(poetry, dev={"bar"}) 3156 | 3157 | exporter = Exporter(poetry, NullIO()) 3158 | exporter.only_groups([MAIN_GROUP, DEV_GROUP]) 3159 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 3160 | 3161 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 3162 | content = f.read() 3163 | 3164 | expected_urls = [ 3165 | f"--extra-index-url https://{name[-1]}.example.com/simple" 3166 | for name in expected[1:] 3167 | ] 3168 | if expected[0]: 3169 | expected_urls = [ 3170 | f"--index-url https://{expected[0]}.example.com/simple", 3171 | *expected_urls, 3172 | ] 3173 | url_string = "\n".join(expected_urls) 3174 | 3175 | expected_content = f"""\ 3176 | {url_string} 3177 | 3178 | bar==4.5.6 ; {MARKER_PY} \\ 3179 | --hash=sha256:67890 3180 | foo==1.2.3 ; {MARKER_PY} \\ 3181 | --hash=sha256:12345 3182 | """ 3183 | 3184 | assert content == expected_content 3185 | 3186 | 3187 | @pytest.mark.parametrize("lock_version", ("1.1", "2.1")) 3188 | def test_dependency_walk_error( 3189 | tmp_path: Path, poetry: Poetry, lock_version: str 3190 | ) -> None: 3191 | """ 3192 | With lock file version 2.1 we can export lock files 3193 | that resulted in a DependencyWalkerError with lower lock file versions. 3194 | 3195 | root 3196 | ├── foo >=0 ; python_version < "3.9" 3197 | ├── foo >=1 ; python_version >= "3.9" 3198 | ├── bar ==1 ; python_version < "3.9" 3199 | │ └── foo ==1 ; python_version < "3.9" 3200 | └── bar ==2 ; python_version >= "3.9" 3201 | └── foo ==2 ; python_version >= "3.9" 3202 | 3203 | Only considering the root dependency, foo 2 is a valid solution 3204 | for all environments. However, due to bar depending on foo, 3205 | foo 1 must be chosen for Python 3.8 and lower. 3206 | """ 3207 | lock_data: dict[str, Any] = { 3208 | "package": [ 3209 | { 3210 | "name": "foo", 3211 | "version": "1", 3212 | "optional": False, 3213 | "python-versions": "*", 3214 | }, 3215 | { 3216 | "name": "foo", 3217 | "version": "2", 3218 | "optional": False, 3219 | "python-versions": "*", 3220 | }, 3221 | { 3222 | "name": "bar", 3223 | "version": "1", 3224 | "optional": False, 3225 | "python-versions": "*", 3226 | "dependencies": {"foo": "1"}, 3227 | }, 3228 | { 3229 | "name": "bar", 3230 | "version": "2", 3231 | "optional": False, 3232 | "python-versions": "*", 3233 | "dependencies": {"foo": "2"}, 3234 | }, 3235 | ], 3236 | "metadata": { 3237 | "lock-version": lock_version, 3238 | "python-versions": "*", 3239 | "content-hash": "123456789", 3240 | "files": {"foo": [], "bar": []}, 3241 | }, 3242 | } 3243 | fix_lock_data(lock_data) 3244 | if lock_version == "2.1": 3245 | lock_data["package"][0]["markers"] = "python_version < '3.9'" 3246 | lock_data["package"][1]["markers"] = "python_version >= '3.9'" 3247 | lock_data["package"][2]["markers"] = "python_version < '3.9'" 3248 | lock_data["package"][3]["markers"] = "python_version >= '3.9'" 3249 | poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] 3250 | poetry.package.python_versions = "^3.8" 3251 | poetry.package.add_dependency( 3252 | Factory.create_dependency( 3253 | name="foo", constraint={"version": ">=0", "python": "<3.9"} 3254 | ) 3255 | ) 3256 | poetry.package.add_dependency( 3257 | Factory.create_dependency( 3258 | name="foo", constraint={"version": ">=1", "python": ">=3.9"} 3259 | ) 3260 | ) 3261 | poetry.package.add_dependency( 3262 | Factory.create_dependency( 3263 | name="bar", constraint={"version": "1", "python": "<3.9"} 3264 | ) 3265 | ) 3266 | poetry.package.add_dependency( 3267 | Factory.create_dependency( 3268 | name="bar", constraint={"version": "2", "python": ">=3.9"} 3269 | ) 3270 | ) 3271 | 3272 | exporter = Exporter(poetry, NullIO()) 3273 | if lock_version == "1.1": 3274 | with pytest.raises(DependencyWalkerError): 3275 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 3276 | return 3277 | 3278 | exporter.export("requirements.txt", tmp_path, "requirements.txt") 3279 | 3280 | with (tmp_path / "requirements.txt").open(encoding="utf-8") as f: 3281 | content = f.read() 3282 | 3283 | expected = """\ 3284 | bar==1 ; python_version == "3.8" 3285 | bar==2 ; python_version >= "3.9" and python_version < "4.0" 3286 | foo==1 ; python_version == "3.8" 3287 | foo==2 ; python_version >= "3.9" and python_version < "4.0" 3288 | """ 3289 | 3290 | assert content == expected 3291 | --------------------------------------------------------------------------------