This commit is contained in:
Ethan Paul 2024-08-16 14:47:12 -04:00 committed by GitHub
commit 295b0748b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1541 additions and 1977 deletions

View File

@ -26,7 +26,6 @@ poetry --version --no-ansi;
poetry run pip --version; poetry run pip --version;
poetry install \ poetry install \
--extras poetry \
--quiet \ --quiet \
--remove-untracked \ --remove-untracked \
--no-ansi; --no-ansi;

View File

@ -12,8 +12,6 @@ jobs:
strategy: strategy:
matrix: matrix:
python: python:
- version: "3.7"
toxenv: py37
- version: "3.8" - version: "3.8"
toxenv: py38 toxenv: py38
- version: "3.9" - version: "3.9"
@ -22,6 +20,8 @@ jobs:
toxenv: py310 toxenv: py310
- version: "3.11" - version: "3.11"
toxenv: py311 toxenv: py311
- version: "3.12"
toxenv: py312
fail-fast: true fail-fast: true
steps: steps:
- name: Checkout - name: Checkout

View File

@ -47,12 +47,13 @@ repos:
- id: reorder-python-imports - id: reorder-python-imports
name: reorder-python-imports name: reorder-python-imports
entry: reorder-python-imports entry: isort
language: system language: system
args: require_serial: true
- "--unclassifiable-application-module=tox_poetry_installer"
types: types:
- python - python
args:
- "--filter-files"
- id: black - id: black
name: black name: black

View File

@ -227,7 +227,6 @@ error will be set to one of the "Status" values below to indicate what the error
| `LockedDepNotFoundError` | Indicates that an item specified in the `locked_deps` config option does not match the name of a package in the Poetry lockfile. | | `LockedDepNotFoundError` | Indicates that an item specified in the `locked_deps` config option does not match the name of a package in the Poetry lockfile. |
| `LockedDepsRequiredError` | Indicates that a test environment with the `require_locked_deps` config option set to `true` also specified unlocked dependencies using the [`deps`](https://tox.readthedocs.io/en/latest/config.html#conf-deps) config option. | | `LockedDepsRequiredError` | Indicates that a test environment with the `require_locked_deps` config option set to `true` also specified unlocked dependencies using the [`deps`](https://tox.readthedocs.io/en/latest/config.html#conf-deps) config option. |
| `PoetryNotInstalledError` | Indicates that the `poetry` module could not be imported under the current runtime environment, and `require_poetry = true` was specified. | | `PoetryNotInstalledError` | Indicates that the `poetry` module could not be imported under the current runtime environment, and `require_poetry = true` was specified. |
| `RequiresUnsafeDepError` | Indicates that the package-under-test depends on a package that Poetry has classified as unsafe and cannot be installed. |
> **Note:** One or more of these errors can be caused by the `pyproject.toml` being out > **Note:** One or more of these errors can be caused by the `pyproject.toml` being out
> of sync with the Poetry lockfile. If this is the case, than a warning will be logged > of sync with the Poetry lockfile. If this is the case, than a warning will be logged

2732
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -23,45 +23,46 @@ classifiers = [
"Natural Language :: English", "Natural Language :: English",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: CPython",
] ]
[tool.poetry.plugins.tox] [tool.poetry.plugins.tox]
poetry_installer = "tox_poetry_installer" poetry_installer = "tox_poetry_installer"
[tool.poetry.extras]
poetry = ["poetry", "cleo"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.7" python = "^3.8"
cleo = {version = ">=1.0,<3.0", optional = true} cleo = ">=1.0,<3.0"
poetry = {version = "^1.5.0", optional = true} poetry = "^1.5.0"
poetry-core = "^1.1.0" poetry-core = "^1.1.0"
tox = "^4" tox = "^4.1"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
bandit = "^1.6.2" bandit = {version = "^1.7.7", python = "^3.10"}
black = "^22.3.0" black = {version = "^24.3.0", python = "^3.10"}
blacken-docs = "^1.8.0" blacken-docs = {version = "^1.18.0", python = "^3.10"}
ipython = {version = "^8.10.1", python = "^3.8"} ipython = {version = "^8.10.1", python = "^3.10"}
mdformat = "^0.7" isort = {version = "^5.13.2", python = "^3.10"}
mdformat-gfm = "^0.3" mdformat = {version = "^0.7", python = "^3.10"}
mypy = "^0.930" mdformat-gfm = {version = "^0.3", python = "^3.10"}
pre-commit = "^2.7.1" mypy = {version = "^0.930", python = "^3.10"}
pre-commit-hooks = "^3.3.0" pre-commit = {version = "^3.8.0", python = "^3.10"}
pylint = "^2.13.0" pre-commit-hooks = {version = "^4.6.0", python = "^3.10"}
pytest = "^6.0.2" pylint = {version = "^3.2.6", python = "^3.10"}
pytest-cov = "^2.10.1" pytest = {version = "^8.3.2", python = "^3.10"}
reorder-python-imports = "^2.3.5" pytest-cov = {version = "^5.0.0", python = "^3.10"}
safety = "^2.2.0" toml = {version = "^0.10.1", python = "^3.10"}
toml = "^0.10.1" tox = "^4.1"
tox = "^4" types-toml = {version = "^0.10.1", python = "^3.10"}
types-toml = "^0.10.1"
[tool.isort]
profile = "black"
force_single_line = "true"
lines_after_imports = 2
[build-system] [build-system]
requires = ["poetry-core>=1.1.0"] requires = ["poetry-core>=1.1.0"]

View File

@ -1,4 +1,4 @@
# pylint: disable=missing-module-docstring, missing-function-docstring, unused-argument, too-few-public-methods # pylint: disable=missing-module-docstring,missing-function-docstring,unused-argument,too-few-public-methods,protected-access
import time import time
from pathlib import Path from pathlib import Path
from typing import List from typing import List
@ -10,7 +10,7 @@ import pytest
import tox.tox_env.python.virtual_env.runner import tox.tox_env.python.virtual_env.runner
from poetry.installation.operations.operation import Operation from poetry.installation.operations.operation import Operation
from tox_poetry_installer import utilities import tox_poetry_installer.hooks._tox_on_install_helpers
TEST_PROJECT_PATH = Path(__file__).parent.resolve() / "test-project" TEST_PROJECT_PATH = Path(__file__).parent.resolve() / "test-project"
@ -47,7 +47,11 @@ class MockExecutor:
@pytest.fixture @pytest.fixture
def mock_venv(monkeypatch): def mock_venv(monkeypatch):
monkeypatch.setattr(utilities, "convert_virtualenv", lambda venv: venv) monkeypatch.setattr(
tox_poetry_installer.hooks._tox_on_install_helpers,
"convert_virtualenv",
lambda venv: venv,
)
monkeypatch.setattr(poetry.installation.executor, "Executor", MockExecutor) monkeypatch.setattr(poetry.installation.executor, "Executor", MockExecutor)
monkeypatch.setattr( monkeypatch.setattr(
tox.tox_env.python.virtual_env.runner, "VirtualEnvRunner", MockVirtualEnv tox.tox_env.python.virtual_env.runner, "VirtualEnvRunner", MockVirtualEnv

View File

@ -1,4 +1,4 @@
# pylint: disable=missing-module-docstring, redefined-outer-name, unused-argument, wrong-import-order, unused-import # pylint: disable=missing-module-docstring,redefined-outer-name,unused-argument,unused-import,protected-access
import time import time
from unittest import mock from unittest import mock
@ -6,23 +6,25 @@ import pytest
import tox.tox_env.python.virtual_env.runner import tox.tox_env.python.virtual_env.runner
from poetry.factory import Factory from poetry.factory import Factory
import tox_poetry_installer.hooks._tox_on_install_helpers
from .fixtures import mock_poetry_factory from .fixtures import mock_poetry_factory
from .fixtures import mock_venv from .fixtures import mock_venv
from tox_poetry_installer import installer
from tox_poetry_installer import utilities
def test_deduplication(mock_venv, mock_poetry_factory): def test_deduplication(mock_venv, mock_poetry_factory):
"""Test that the installer does not install duplicate dependencies""" """Test that the installer does not install duplicate dependencies"""
poetry = Factory().create_poetry(None) poetry = Factory().create_poetry(None)
packages: utilities.PackageMap = { packages: tox_poetry_installer.hooks._tox_on_install_helpers.PackageMap = {
item.name: item for item in poetry.locker.locked_repository().packages item.name: item for item in poetry.locker.locked_repository().packages
} }
venv = tox.tox_env.python.virtual_env.runner.VirtualEnvRunner() venv = tox.tox_env.python.virtual_env.runner.VirtualEnvRunner()
to_install = [packages["toml"], packages["toml"]] to_install = [packages["toml"], packages["toml"]]
installer.install(poetry, venv, to_install) tox_poetry_installer.hooks._tox_on_install_helpers.install_package(
poetry, venv, to_install
)
assert len(set(to_install)) == len(venv.installed) # pylint: disable=no-member assert len(set(to_install)) == len(venv.installed) # pylint: disable=no-member
@ -30,7 +32,7 @@ def test_deduplication(mock_venv, mock_poetry_factory):
def test_parallelization(mock_venv, mock_poetry_factory): def test_parallelization(mock_venv, mock_poetry_factory):
"""Test that behavior is consistent between parallel and non-parallel usage""" """Test that behavior is consistent between parallel and non-parallel usage"""
poetry = Factory().create_poetry(None) poetry = Factory().create_poetry(None)
packages: utilities.PackageMap = { packages: tox_poetry_installer.hooks._tox_on_install_helpers.PackageMap = {
item.name: item for item in poetry.locker.locked_repository().packages item.name: item for item in poetry.locker.locked_repository().packages
} }
@ -45,12 +47,16 @@ def test_parallelization(mock_venv, mock_poetry_factory):
venv_sequential = tox.tox_env.python.virtual_env.runner.VirtualEnvRunner() venv_sequential = tox.tox_env.python.virtual_env.runner.VirtualEnvRunner()
start_sequential = time.time() start_sequential = time.time()
installer.install(poetry, venv_sequential, to_install, 0) tox_poetry_installer.hooks._tox_on_install_helpers.install_package(
poetry, venv_sequential, to_install, 0
)
sequential = time.time() - start_sequential sequential = time.time() - start_sequential
venv_parallel = tox.tox_env.python.virtual_env.runner.VirtualEnvRunner() venv_parallel = tox.tox_env.python.virtual_env.runner.VirtualEnvRunner()
start_parallel = time.time() start_parallel = time.time()
installer.install(poetry, venv_parallel, to_install, 5) tox_poetry_installer.hooks._tox_on_install_helpers.install_package(
poetry, venv_parallel, to_install, 5
)
parallel = time.time() - start_parallel parallel = time.time() - start_parallel
# The mock delay during package install is static (one second) so these values should all # The mock delay during package install is static (one second) so these values should all
@ -72,7 +78,7 @@ def test_propagates_exceptions_during_installation(
from tox_poetry_installer import _poetry # pylint: disable=import-outside-toplevel from tox_poetry_installer import _poetry # pylint: disable=import-outside-toplevel
poetry = Factory().create_poetry(None) poetry = Factory().create_poetry(None)
packages: utilities.PackageMap = { packages: tox_poetry_installer.hooks._tox_on_install_helpers.PackageMap = {
item.name: item for item in poetry.locker.locked_repository().packages item.name: item for item in poetry.locker.locked_repository().packages
} }
to_install = [packages["toml"]] to_install = [packages["toml"]]
@ -85,6 +91,8 @@ def test_propagates_exceptions_during_installation(
**{"return_value.execute.side_effect": fake_exception}, **{"return_value.execute.side_effect": fake_exception},
): ):
with pytest.raises(ValueError) as exc_info: with pytest.raises(ValueError) as exc_info:
installer.install(poetry, venv, to_install, num_threads) tox_poetry_installer.hooks._tox_on_install_helpers.install_package(
poetry, venv, to_install, num_threads
)
assert exc_info.value is fake_exception assert exc_info.value is fake_exception

View File

@ -3,6 +3,7 @@
The next best thing to having one source of truth is having a way to ensure all of your The next best thing to having one source of truth is having a way to ensure all of your
sources of truth agree with each other. sources of truth agree with each other.
""" """
from pathlib import Path from pathlib import Path
import toml import toml

View File

@ -1,33 +1,23 @@
# pylint: disable=missing-module-docstring, redefined-outer-name, unused-argument, wrong-import-order, unused-import # pylint: disable=missing-module-docstring,redefined-outer-name,unused-argument,unused-import,protected-access
import poetry.factory import poetry.factory
import poetry.utils.env import poetry.utils.env
import pytest import pytest
from poetry.puzzle.provider import Provider
import tox_poetry_installer.hooks._tox_on_install_helpers
from tox_poetry_installer import exceptions
from .fixtures import mock_poetry_factory from .fixtures import mock_poetry_factory
from .fixtures import mock_venv from .fixtures import mock_venv
from tox_poetry_installer import constants
from tox_poetry_installer import exceptions
from tox_poetry_installer import utilities
def test_exclude_unsafe():
"""Test that the unsafe packages are properly excluded
Also ensure that the internal constant matches the value from Poetry
"""
assert Provider.UNSAFE_PACKAGES == constants.UNSAFE_PACKAGES
for dep in constants.UNSAFE_PACKAGES:
assert not utilities.identify_transients(dep, {}, None)
def test_allow_missing(): def test_allow_missing():
"""Test that the ``allow_missing`` parameter works as expected""" """Test that the ``allow_missing`` parameter works as expected"""
with pytest.raises(exceptions.LockedDepNotFoundError): with pytest.raises(exceptions.LockedDepNotFoundError):
utilities.identify_transients("luke-skywalker", {}, None) tox_poetry_installer.hooks._tox_on_install_helpers.identify_transients(
"luke-skywalker", {}, None
)
assert not utilities.identify_transients( assert not tox_poetry_installer.hooks._tox_on_install_helpers.identify_transients(
"darth-vader", {}, None, allow_missing=["darth-vader"] "darth-vader", {}, None, allow_missing=["darth-vader"]
) )
@ -47,7 +37,9 @@ def test_exclude_pep508():
"=>foo", "=>foo",
]: ]:
with pytest.raises(exceptions.LockedDepVersionConflictError): with pytest.raises(exceptions.LockedDepVersionConflictError):
utilities.identify_transients(version, {}, None) tox_poetry_installer.hooks._tox_on_install_helpers.identify_transients(
version, {}, None
)
def test_functional(mock_poetry_factory, mock_venv): def test_functional(mock_poetry_factory, mock_venv):
@ -57,7 +49,9 @@ def test_functional(mock_poetry_factory, mock_venv):
is always the last in the returned list. is always the last in the returned list.
""" """
pypoetry = poetry.factory.Factory().create_poetry(None) pypoetry = poetry.factory.Factory().create_poetry(None)
packages = utilities.build_package_map(pypoetry) packages = tox_poetry_installer.hooks._tox_on_install_helpers.build_package_map(
pypoetry
)
venv = poetry.utils.env.VirtualEnv() # pylint: disable=no-value-for-parameter venv = poetry.utils.env.VirtualEnv() # pylint: disable=no-value-for-parameter
requests_requires = [ requests_requires = [
@ -68,12 +62,18 @@ def test_functional(mock_poetry_factory, mock_venv):
packages["requests"][0], packages["requests"][0],
] ]
transients = utilities.identify_transients("requests", packages, venv) transients = tox_poetry_installer.hooks._tox_on_install_helpers.identify_transients(
"requests", packages, venv
)
assert all((item in requests_requires) for item in transients) assert all((item in requests_requires) for item in transients)
assert all((item in transients) for item in requests_requires) assert all((item in transients) for item in requests_requires)
for package in [packages["requests"][0], packages["tox"][0], packages["flask"][0]]: for package in [packages["requests"][0], packages["tox"][0], packages["flask"][0]]:
transients = utilities.identify_transients(package.name, packages, venv) transients = (
tox_poetry_installer.hooks._tox_on_install_helpers.identify_transients(
package.name, packages, venv
)
)
assert transients[-1] == package assert transients[-1] == package
assert len(transients) == len(set(transients)) assert len(transients) == len(set(transients))

18
tox.ini
View File

@ -1,13 +1,11 @@
[tox] [tox]
envlist = py37, py38, py39, py310, py311, static, static-tests, security envlist = py3{8,9,10,11,12} static, static-tests, security
skip_missing_interpreters = true skip_missing_interpreters = true
[testenv] [testenv]
description = Run the tests description = Run the tests
require_locked_deps = true require_locked_deps = true
require_poetry = true require_poetry = true
extras =
poetry
locked_deps = locked_deps =
pytest pytest
pytest-cov pytest-cov
@ -26,10 +24,10 @@ ignore_errors = true
locked_deps = locked_deps =
black black
blacken-docs blacken-docs
isort
mdformat mdformat
mdformat-gfm mdformat-gfm
mypy mypy
reorder-python-imports
pre-commit pre-commit
pre-commit-hooks pre-commit-hooks
pylint pylint
@ -52,6 +50,7 @@ locked_deps =
pylint pylint
pytest pytest
mypy mypy
toml
types-toml types-toml
commands = commands =
pylint {toxinidir}/tests/ \ pylint {toxinidir}/tests/ \
@ -78,14 +77,3 @@ commands =
--recursive \ --recursive \
--quiet \ --quiet \
--skip B101 --skip B101
poetry export \
--format requirements.txt \
--output {envtmpdir}/requirements.txt \
--without-hashes \
--with dev \
--extras poetry
safety check \
--file {envtmpdir}/requirements.txt \
--output text \
# https://github.com/pytest-dev/py/issues/287
--ignore 51457

View File

@ -21,13 +21,14 @@ at the module scope it is imported into function scope wherever Poetry component
moves import errors from load time to runtime which allows the plugin to be skipped if Poetry isn't moves import errors from load time to runtime which allows the plugin to be skipped if Poetry isn't
installed and/or a more helpful error be raised within the Tox framework. installed and/or a more helpful error be raised within the Tox framework.
""" """
# pylint: disable=unused-import
import sys import sys
from tox_poetry_installer import exceptions from tox_poetry_installer import exceptions
try: try:
# pylint: disable=import-outside-toplevel,unused-import
from cleo.io.null_io import NullIO from cleo.io.null_io import NullIO
from poetry.config.config import Config from poetry.config.config import Config
from poetry.core.packages.dependency import Dependency as PoetryDependency from poetry.core.packages.dependency import Dependency as PoetryDependency
@ -37,7 +38,7 @@ try:
from poetry.installation.operations.install import Install from poetry.installation.operations.install import Install
from poetry.poetry import Poetry from poetry.poetry import Poetry
from poetry.utils.env import VirtualEnv from poetry.utils.env import VirtualEnv
except ImportError: except ImportError as err:
raise exceptions.PoetryNotInstalledError( raise exceptions.PoetryNotInstalledError(
f"No version of Poetry could be imported under the current environment for '{sys.executable}'" f"Failed to import a supported version of Poetry under the current environment '{sys.executable}': {err}"
) from None ) from None

View File

@ -5,7 +5,7 @@ in this module.
All constants should be type hinted. All constants should be type hinted.
""" """
from typing import Set
from typing import Tuple from typing import Tuple
from tox_poetry_installer import __about__ from tox_poetry_installer import __about__
@ -19,9 +19,5 @@ PEP508_VERSION_DELIMITERS: Tuple[str, ...] = ("~=", "==", "!=", ">", "<")
# console output. # console output.
REPORTER_PREFIX: str = f"{__about__.__title__}:" REPORTER_PREFIX: str = f"{__about__.__title__}:"
# Internal list of packages that poetry has deemed unsafe and are excluded from the lockfile
# TODO: This functionality is no longer needed, should remove in a future update.
UNSAFE_PACKAGES: Set[str] = set()
# Number of threads to use for installing dependencies by default # Number of threads to use for installing dependencies by default
DEFAULT_INSTALL_THREADS: int = 10 DEFAULT_INSTALL_THREADS: int = 10

View File

@ -11,7 +11,7 @@ All exceptions should inherit from the common base exception :exc:`ToxPoetryInst
+-- LockedDepNotFoundError +-- LockedDepNotFoundError
+-- ExtraNotFoundError +-- ExtraNotFoundError
+-- LockedDepsRequiredError +-- LockedDepsRequiredError
+-- RequiresUnsafeDepError +-- LockfileParsingError
""" """
@ -44,5 +44,5 @@ class LockedDepsRequiredError(ToxPoetryInstallerException):
"""Environment cannot specify unlocked dependencies when locked dependencies are required""" """Environment cannot specify unlocked dependencies when locked dependencies are required"""
class RequiresUnsafeDepError(ToxPoetryInstallerException): class LockfileParsingError(ToxPoetryInstallerException):
"""Package under test depends on an unsafe dependency and cannot be installed""" """Failed to load or parse the Poetry lockfile"""

View File

@ -1,175 +0,0 @@
"""Main hook definition module
All implementations of tox hooks are defined here, as well as any single-use helper functions
specifically related to implementing the hooks (to keep the size/readability of the hook functions
themselves manageable).
"""
from itertools import chain
from typing import List
from tox.config.cli.parser import ToxParser
from tox.config.sets import EnvConfigSet
from tox.plugin import impl
from tox.tox_env.api import ToxEnv as ToxVirtualEnv
from tox_poetry_installer import constants
from tox_poetry_installer import exceptions
from tox_poetry_installer import installer
from tox_poetry_installer import logger
from tox_poetry_installer import utilities
@impl
def tox_add_option(parser: ToxParser):
"""Add additional command line arguments to tox to configure plugin behavior"""
parser.add_argument(
"--require-poetry",
action="store_true",
dest="require_poetry",
help="(deprecated) Trigger a failure if Poetry is not available to Tox",
)
parser.add_argument(
"--parallel-install-threads",
type=int,
dest="parallel_install_threads",
default=constants.DEFAULT_INSTALL_THREADS,
help="Number of locked dependencies to install simultaneously; set to 0 to disable parallel installation",
)
@impl
def tox_add_env_config(env_conf: EnvConfigSet):
"""Add required env configuration options to the tox INI file"""
env_conf.add_config(
"poetry_dep_groups",
of_type=List[str],
default=[],
desc="List of Poetry dependency groups to install to the environment",
)
env_conf.add_config(
"install_project_deps",
of_type=bool,
default=True,
desc="Automatically install all Poetry primary dependencies to the environment",
)
env_conf.add_config(
"require_locked_deps",
of_type=bool,
default=False,
desc="Require all dependencies in the environment be installed using the Poetry lockfile",
)
env_conf.add_config(
"require_poetry",
of_type=bool,
default=False,
desc="Trigger a failure if Poetry is not available to Tox",
)
env_conf.add_config(
"locked_deps",
of_type=List[str],
default=[],
desc="List of locked dependencies to install to the environment using the Poetry lockfile",
)
@impl
def tox_on_install(
tox_env: ToxVirtualEnv, section: str # pylint: disable=unused-argument
) -> None:
"""Install the dependencies for the current environment
Loads the local Poetry environment and the corresponding lockfile then pulls the dependencies
specified by the Tox environment. Finally these dependencies are installed into the Tox
environment using the Poetry ``PipInstaller`` backend.
:param venv: Tox virtual environment object with configuration for the local Tox environment.
:param action: Tox action object
"""
try:
poetry = utilities.check_preconditions(tox_env)
except exceptions.SkipEnvironment as err:
if (
isinstance(err, exceptions.PoetryNotInstalledError)
and tox_env.conf["require_poetry"]
):
logger.error(str(err))
raise err
logger.info(str(err))
return
logger.info(f"Loaded project pyproject.toml from {poetry.file}")
virtualenv = utilities.convert_virtualenv(tox_env)
if not poetry.locker.is_fresh():
logger.warning(
f"The Poetry lock file is not up to date with the latest changes in {poetry.file}"
)
try:
if tox_env.conf["require_locked_deps"] and tox_env.conf["deps"].lines():
raise exceptions.LockedDepsRequiredError(
f"Unlocked dependencies '{tox_env.conf['deps']}' specified for environment '{tox_env.name}' which requires locked dependencies"
)
packages = utilities.build_package_map(poetry)
group_deps = utilities.dedupe_packages(
list(
chain(
*[
utilities.find_group_deps(group, packages, virtualenv, poetry)
for group in tox_env.conf["poetry_dep_groups"]
]
)
)
)
logger.info(
f"Identified {len(group_deps)} group dependencies to install to env"
)
env_deps = utilities.find_additional_deps(
packages, virtualenv, poetry, tox_env.conf["locked_deps"]
)
logger.info(
f"Identified {len(env_deps)} environment dependencies to install to env"
)
# extras are not set in a testenv if skip_install=true
try:
extras = tox_env.conf["extras"]
except KeyError:
extras = []
if tox_env.conf["install_project_deps"]:
project_deps = utilities.find_project_deps(
packages, virtualenv, poetry, extras
)
logger.info(
f"Identified {len(project_deps)} project dependencies to install to env"
)
else:
project_deps = []
logger.info("Env does not install project package dependencies, skipping")
except exceptions.ToxPoetryInstallerException as err:
logger.error(str(err))
raise err
except Exception as err:
logger.error(f"Internal plugin error: {err}")
raise err
dependencies = utilities.dedupe_packages(group_deps + env_deps + project_deps)
logger.info(f"Installing {len(dependencies)} dependencies from Poetry lock file")
installer.install(
poetry,
tox_env,
dependencies,
tox_env.options.parallel_install_threads,
)

View File

@ -0,0 +1,4 @@
# pylint: disable=missing-module-docstring
from tox_poetry_installer.hooks.tox_add_env_config import tox_add_env_config
from tox_poetry_installer.hooks.tox_add_option import tox_add_option
from tox_poetry_installer.hooks.tox_on_install import tox_on_install

View File

@ -1,10 +1,12 @@
"""Helper utility functions, usually bridging Tox and Poetry functionality""" """Helper functions for the :func:`tox_on_install` hook"""
# Silence this one globally to support the internal function imports for the proxied poetry module.
# See the docstring in 'tox_poetry_installer._poetry' for more context.
# pylint: disable=import-outside-toplevel
import collections import collections
import concurrent.futures
import contextlib
import typing import typing
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Collection
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Sequence from typing import Sequence
@ -19,9 +21,13 @@ from tox_poetry_installer import constants
from tox_poetry_installer import exceptions from tox_poetry_installer import exceptions
from tox_poetry_installer import logger from tox_poetry_installer import logger
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from tox_poetry_installer import _poetry from tox_poetry_installer import _poetry
# This is globally disabled to support the usage of the _poetry shadow module
# pylint: disable=import-outside-toplevel
PackageMap = Dict[str, List[PoetryPackage]] PackageMap = Dict[str, List[PoetryPackage]]
@ -37,13 +43,6 @@ def check_preconditions(venv: ToxVirtualEnv) -> "_poetry.Poetry":
if isinstance(venv, PackageToxEnv): if isinstance(venv, PackageToxEnv):
raise exceptions.SkipEnvironment(f"Skipping Tox provisioning env '{venv.name}'") raise exceptions.SkipEnvironment(f"Skipping Tox provisioning env '{venv.name}'")
if venv.options.require_poetry:
logger.warning(
"DEPRECATION: The '--require-poetry' runtime option is deprecated and will be "
"removed in version 1.0.0. Please update test environments that require Poetry to "
"set the 'require_poetry = true' option in tox.ini"
)
from tox_poetry_installer import _poetry from tox_poetry_installer import _poetry
try: try:
@ -53,36 +52,12 @@ def check_preconditions(venv: ToxVirtualEnv) -> "_poetry.Poetry":
# #
# ``RuntimeError`` is dangerous to blindly catch because it can be (and in Poetry's case, # ``RuntimeError`` is dangerous to blindly catch because it can be (and in Poetry's case,
# is) raised in many different places for different purposes. # is) raised in many different places for different purposes.
except RuntimeError: except RuntimeError as err:
raise exceptions.SkipEnvironment( raise exceptions.SkipEnvironment(
"Project does not use Poetry for env management, skipping installation of locked dependencies" f"Skipping installation of locked dependencies due to a Poetry error: {err}"
) from None ) from None
def convert_virtualenv(venv: ToxVirtualEnv) -> "_poetry.VirtualEnv":
"""Convert a Tox venv to a Poetry venv
:param venv: Tox ``VirtualEnv`` object representing a tox virtual environment
:returns: Poetry ``VirtualEnv`` object representing a poetry virtual environment
"""
from tox_poetry_installer import _poetry
return _poetry.VirtualEnv(path=Path(venv.env_dir))
def build_package_map(poetry: "_poetry.Poetry") -> PackageMap:
"""Build the mapping of package names to objects
:param poetry: Populated poetry object to load locked packages from
:returns: Mapping of package names to Poetry package objects
"""
packages = collections.defaultdict(list)
for package in poetry.locker.locked_repository().packages:
packages[package.name].append(package)
return packages
def identify_transients( def identify_transients(
dep_name: str, dep_name: str,
packages: PackageMap, packages: PackageMap,
@ -139,13 +114,6 @@ def identify_transients(
except KeyError as err: except KeyError as err:
missing = err.args[0] missing = err.args[0]
if missing in constants.UNSAFE_PACKAGES:
logger.warning(
f"Installing package '{missing}' using Poetry is not supported and will be skipped"
)
logger.debug(f"Skipping {missing}: designated unsafe by Poetry")
return []
if missing in allow_missing: if missing in allow_missing:
logger.debug(f"Skipping {missing}: package is allowed to be unlocked") logger.debug(f"Skipping {missing}: package is allowed to be unlocked")
return [] return []
@ -178,11 +146,6 @@ def find_project_deps(
:param extras: Sequence of extra names to include the dependencies of :param extras: Sequence of extra names to include the dependencies of
""" """
if any(dep.name in constants.UNSAFE_PACKAGES for dep in poetry.package.requires):
raise exceptions.RequiresUnsafeDepError(
f"Project package requires one or more unsafe dependencies ({', '.join(constants.UNSAFE_PACKAGES)}) which cannot be installed with Poetry"
)
required_dep_names = [ required_dep_names = [
item.name for item in poetry.package.requires if not item.is_optional() item.name for item in poetry.package.requires if not item.is_optional()
] ]
@ -283,6 +246,76 @@ def find_dev_deps(
return dedupe_packages(dev_group_deps + legacy_dev_group_deps) return dedupe_packages(dev_group_deps + legacy_dev_group_deps)
def install_package(
poetry: "_poetry.Poetry",
venv: ToxVirtualEnv,
packages: Collection["_poetry.PoetryPackage"],
parallels: int = 0,
):
"""Install a bunch of packages to a virtualenv
:param poetry: Poetry object the packages were sourced from
:param venv: Tox virtual environment to install the packages to
:param packages: List of packages to install to the virtual environment
:param parallels: Number of parallel processes to use for installing dependency packages, or
``None`` to disable parallelization.
"""
from tox_poetry_installer import _poetry
logger.info(f"Installing {len(packages)} packages to environment at {venv.env_dir}")
install_executor = _poetry.Executor(
env=convert_virtualenv(venv),
io=_poetry.NullIO(),
pool=poetry.pool,
config=_poetry.Config(),
)
installed: Set[_poetry.PoetryPackage] = set()
def logged_install(dependency: _poetry.PoetryPackage) -> None:
start = datetime.now()
logger.debug(f"Installing {dependency}")
install_executor.execute([_poetry.Install(package=dependency)])
end = datetime.now()
logger.debug(f"Finished installing {dependency} in {end - start}")
@contextlib.contextmanager
def _optional_parallelize():
"""A bit of cheat, really
A context manager that exposes a common interface for the caller that optionally
enables/disables the usage of the parallel thread pooler depending on the value of
the ``parallels`` parameter.
"""
if parallels > 0:
with concurrent.futures.ThreadPoolExecutor(
max_workers=parallels
) as executor:
yield executor.submit
else:
yield lambda func, arg: func(arg)
with _optional_parallelize() as executor:
futures = []
for dependency in packages:
if dependency not in installed:
installed.add(dependency)
logger.debug(f"Queuing {dependency}")
future = executor(logged_install, dependency)
if future is not None:
futures.append(future)
else:
logger.debug(f"Skipping {dependency}, already installed")
logger.debug("Waiting for installs to finish...")
for future in concurrent.futures.as_completed(futures):
# Don't actually care about the return value, just waiting on the
# future to ensure any exceptions that were raised in the called
# function are propagated.
future.result()
def dedupe_packages(packages: Sequence[PoetryPackage]) -> List[PoetryPackage]: def dedupe_packages(packages: Sequence[PoetryPackage]) -> List[PoetryPackage]:
"""Deduplicates a sequence of PoetryPackages while preserving ordering """Deduplicates a sequence of PoetryPackages while preserving ordering
@ -292,3 +325,27 @@ def dedupe_packages(packages: Sequence[PoetryPackage]) -> List[PoetryPackage]:
# Make this faster, avoid method lookup below # Make this faster, avoid method lookup below
seen_add = seen.add seen_add = seen.add
return [p for p in packages if not (p in seen or seen_add(p))] return [p for p in packages if not (p in seen or seen_add(p))]
def convert_virtualenv(venv: ToxVirtualEnv) -> "_poetry.VirtualEnv":
"""Convert a Tox venv to a Poetry venv
:param venv: Tox ``VirtualEnv`` object representing a tox virtual environment
:returns: Poetry ``VirtualEnv`` object representing a poetry virtual environment
"""
from tox_poetry_installer import _poetry
return _poetry.VirtualEnv(path=Path(venv.env_dir))
def build_package_map(poetry: "_poetry.Poetry") -> PackageMap:
"""Build the mapping of package names to objects
:param poetry: Populated poetry object to load locked packages from
:returns: Mapping of package names to Poetry package objects
"""
packages = collections.defaultdict(list)
for package in poetry.locker.locked_repository().packages:
packages[package.name].append(package)
return packages

View File

@ -0,0 +1,47 @@
"""Add required env configuration options to the tox INI file"""
from typing import List
from tox.config.sets import EnvConfigSet
from tox.plugin import impl
# pylint: disable=missing-function-docstring
@impl
def tox_add_env_config(
env_conf: EnvConfigSet,
):
env_conf.add_config(
"poetry_dep_groups",
of_type=List[str],
default=[],
desc="List of Poetry dependency groups to install to the environment",
)
env_conf.add_config(
"install_project_deps",
of_type=bool,
default=True,
desc="Automatically install all Poetry primary dependencies to the environment",
)
env_conf.add_config(
"require_locked_deps",
of_type=bool,
default=False,
desc="Require all dependencies in the environment be installed using the Poetry lockfile",
)
env_conf.add_config(
"require_poetry",
of_type=bool,
default=False,
desc="Trigger a failure if Poetry is not available to Tox",
)
env_conf.add_config(
"locked_deps",
of_type=List[str],
default=[],
desc="List of locked dependencies to install to the environment using the Poetry lockfile",
)

View File

@ -0,0 +1,18 @@
"""Add additional command line arguments to tox to configure plugin behavior"""
from tox.config.cli.parser import ToxParser
from tox.plugin import impl
from tox_poetry_installer import constants
# pylint: disable=missing-function-docstring
@impl
def tox_add_option(parser: ToxParser):
parser.add_argument(
"--parallel-install-threads",
type=int,
dest="parallel_install_threads",
default=constants.DEFAULT_INSTALL_THREADS,
help="Number of locked dependencies to install simultaneously; set to 0 to disable parallel installation",
)

View File

@ -0,0 +1,114 @@
"""Install the dependencies for the current environment
Loads the local Poetry environment and the corresponding lockfile then pulls the dependencies
specified by the Tox environment. Finally these dependencies are installed into the Tox
environment using the Poetry ``PipInstaller`` backend.
"""
from itertools import chain
from tox.plugin import impl
from tox.tox_env.api import ToxEnv as ToxVirtualEnv
from tox_poetry_installer import exceptions
from tox_poetry_installer import logger
from tox_poetry_installer.hooks._tox_on_install_helpers import build_package_map
from tox_poetry_installer.hooks._tox_on_install_helpers import check_preconditions
from tox_poetry_installer.hooks._tox_on_install_helpers import convert_virtualenv
from tox_poetry_installer.hooks._tox_on_install_helpers import dedupe_packages
from tox_poetry_installer.hooks._tox_on_install_helpers import find_additional_deps
from tox_poetry_installer.hooks._tox_on_install_helpers import find_group_deps
from tox_poetry_installer.hooks._tox_on_install_helpers import find_project_deps
from tox_poetry_installer.hooks._tox_on_install_helpers import install_package
# pylint: disable=missing-function-docstring,unused-argument
@impl
def tox_on_install(tox_env: ToxVirtualEnv, *args) -> None:
try:
poetry = check_preconditions(tox_env)
except exceptions.SkipEnvironment as err:
if (
isinstance(err, exceptions.PoetryNotInstalledError)
and tox_env.conf["require_poetry"]
):
logger.error(str(err))
raise err
logger.info(str(err))
return
logger.info(f"Loaded project pyproject.toml from {poetry.file}")
virtualenv = convert_virtualenv(tox_env)
try:
if not poetry.locker.is_fresh():
logger.warning(
f"The Poetry lock file is not up to date with the latest changes in {poetry.file}"
)
except FileNotFoundError as err:
logger.error(f"Could not parse lockfile: {err}")
raise exceptions.LockfileParsingError(
f"Could not parse lockfile: {err}"
) from err
try:
if tox_env.conf["require_locked_deps"] and tox_env.conf["deps"].lines():
raise exceptions.LockedDepsRequiredError(
f"Unlocked dependencies '{tox_env.conf['deps']}' specified for environment '{tox_env.name}' which requires locked dependencies"
)
packages = build_package_map(poetry)
group_deps = dedupe_packages(
list(
chain(
*[
find_group_deps(group, packages, virtualenv, poetry)
for group in tox_env.conf["poetry_dep_groups"]
]
)
)
)
logger.info(
f"Identified {len(group_deps)} group dependencies to install to env"
)
env_deps = find_additional_deps(
packages, virtualenv, poetry, tox_env.conf["locked_deps"]
)
logger.info(
f"Identified {len(env_deps)} environment dependencies to install to env"
)
# extras are not set in a testenv if skip_install=true
try:
extras = tox_env.conf["extras"]
except KeyError:
extras = []
if tox_env.conf["install_project_deps"]:
project_deps = find_project_deps(packages, virtualenv, poetry, extras)
logger.info(
f"Identified {len(project_deps)} project dependencies to install to env"
)
else:
project_deps = []
logger.info("Env does not install project package dependencies, skipping")
except exceptions.ToxPoetryInstallerException as err:
logger.error(str(err))
raise err
except Exception as err:
logger.error(f"Internal plugin error: {err}")
raise err
dependencies = dedupe_packages(group_deps + env_deps + project_deps)
logger.info(f"Installing {len(dependencies)} dependencies from Poetry lock file")
install_package(
poetry,
tox_env,
dependencies,
tox_env.options.parallel_install_threads,
)

View File

@ -1,88 +0,0 @@
"""Funcationality for performing virtualenv installation"""
# Silence this one globally to support the internal function imports for the proxied poetry module.
# See the docstring in 'tox_poetry_installer._poetry' for more context.
# pylint: disable=import-outside-toplevel
import concurrent.futures
import contextlib
import typing
from datetime import datetime
from typing import Collection
from typing import Set
from tox.tox_env.api import ToxEnv as ToxVirtualEnv
from tox_poetry_installer import logger
from tox_poetry_installer import utilities
if typing.TYPE_CHECKING:
from tox_poetry_installer import _poetry
def install(
poetry: "_poetry.Poetry",
venv: ToxVirtualEnv,
packages: Collection["_poetry.PoetryPackage"],
parallels: int = 0,
):
"""Install a bunch of packages to a virtualenv
:param poetry: Poetry object the packages were sourced from
:param venv: Tox virtual environment to install the packages to
:param packages: List of packages to install to the virtual environment
:param parallels: Number of parallel processes to use for installing dependency packages, or
``None`` to disable parallelization.
"""
from tox_poetry_installer import _poetry
logger.info(f"Installing {len(packages)} packages to environment at {venv.env_dir}")
install_executor = _poetry.Executor(
env=utilities.convert_virtualenv(venv),
io=_poetry.NullIO(),
pool=poetry.pool,
config=_poetry.Config(),
)
installed: Set[_poetry.PoetryPackage] = set()
def logged_install(dependency: _poetry.PoetryPackage) -> None:
start = datetime.now()
logger.debug(f"Installing {dependency}")
install_executor.execute([_poetry.Install(package=dependency)])
end = datetime.now()
logger.debug(f"Finished installing {dependency} in {end - start}")
@contextlib.contextmanager
def _optional_parallelize():
"""A bit of cheat, really
A context manager that exposes a common interface for the caller that optionally
enables/disables the usage of the parallel thread pooler depending on the value of
the ``parallels`` parameter.
"""
if parallels > 0:
with concurrent.futures.ThreadPoolExecutor(
max_workers=parallels
) as executor:
yield executor.submit
else:
yield lambda func, arg: func(arg)
with _optional_parallelize() as executor:
futures = []
for dependency in packages:
if dependency not in installed:
installed.add(dependency)
logger.debug(f"Queuing {dependency}")
future = executor(logged_install, dependency)
if future is not None:
futures.append(future)
else:
logger.debug(f"Skipping {dependency}, already installed")
logger.debug("Waiting for installs to finish...")
for future in concurrent.futures.as_completed(futures):
# Don't actually care about the return value, just waiting on the
# future to ensure any exceptions that were raised in the called
# function are propagated.
future.result()

View File

@ -4,6 +4,7 @@ Calling ``tox.reporter.something()`` and having to format a string with the pref
gets really old fast, but more importantly it also makes the flow of the main code gets really old fast, but more importantly it also makes the flow of the main code
more difficult to follow because of the added complexity. more difficult to follow because of the added complexity.
""" """
import logging import logging
from tox_poetry_installer import constants from tox_poetry_installer import constants