Compare commits

..

7 Commits

Author SHA1 Message Date
b19c7e806a
Fix linting errors
Remove unused imports
Disable redundant errors
Add notes for why errors are disabled
2024-08-15 14:41:34 -04:00
27ef531762
Add handling of error when poetry.lock does not exist
Fixes #81
2024-08-15 13:55:37 -04:00
92b72435f4
Replace optional [poetry] extra with explicit poetry dependencies
Fixes #79
2024-08-15 13:55:37 -04:00
5c4d861230
Consolidate all package handling logic into hook module
This creates a large module to sort through, but the hope is that it
avoids the need to constantly hop around without rhyme or reason to
find the piece of logic you're looking for. The module structure is
mapped to functionality rather than an arbitrary concept of reducing
line number.
2024-08-15 13:55:36 -04:00
f3ae242cf7
Add import exception to Poetry import error message
Fixes #96
2024-08-15 13:55:19 -04:00
57787c6206
Reorganize hooks into dedicated submodule
Fixes #93
2024-08-15 13:55:18 -04:00
661072a69f
Remove unsafe dependency check
Fixes #97
2024-08-15 13:55:17 -04:00
12 changed files with 424 additions and 387 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

@ -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,24 @@ 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 +31,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 +46,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 +77,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 +90,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

@ -1,33 +1,22 @@
# 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 .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 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 +36,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 +48,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 +61,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))

View File

@ -6,8 +6,6 @@ skip_missing_interpreters = true
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

View File

@ -21,13 +21,13 @@ 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

View File

@ -5,7 +5,6 @@ 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__

View File

@ -11,6 +11,7 @@ All exceptions should inherit from the common base exception :exc:`ToxPoetryInst
+-- LockedDepNotFoundError +-- LockedDepNotFoundError
+-- ExtraNotFoundError +-- ExtraNotFoundError
+-- LockedDepsRequiredError +-- LockedDepsRequiredError
+-- LockfileParsingError
""" """
@ -41,3 +42,7 @@ class ExtraNotFoundError(ToxPoetryInstallerException):
class LockedDepsRequiredError(ToxPoetryInstallerException): class LockedDepsRequiredError(ToxPoetryInstallerException):
"""Environment cannot specify unlocked dependencies when locked dependencies are required""" """Environment cannot specify unlocked dependencies when locked dependencies are required"""
class LockfileParsingError(ToxPoetryInstallerException):
"""Failed to load or parse the Poetry lockfile"""

View File

@ -0,0 +1,349 @@
"""Helper functions for the :func:`tox_on_install` hook"""
import collections
import concurrent.futures
import contextlib
import typing
from datetime import datetime
from pathlib import Path
from typing import Collection
from typing import Dict
from typing import List
from typing import Sequence
from typing import Set
from poetry.core.packages.dependency import Dependency as PoetryDependency
from poetry.core.packages.package import Package as PoetryPackage
from tox.tox_env.api import ToxEnv as ToxVirtualEnv
from tox.tox_env.package import PackageToxEnv
from tox_poetry_installer import constants
from tox_poetry_installer import exceptions
from tox_poetry_installer import logger
if typing.TYPE_CHECKING:
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]]
def check_preconditions(venv: ToxVirtualEnv) -> "_poetry.Poetry":
"""Check that the local project environment meets expectations"""
# Skip running the plugin for the provisioning environment. The provisioned environment,
# for alternative Tox versions and/or the ``requires`` meta dependencies is specially
# handled by Tox and is out of scope for this plugin. Since one of the ways to install this
# plugin in the first place is via the Tox provisioning environment, it quickly becomes a
# chicken-and-egg problem.
if isinstance(venv, PackageToxEnv):
raise exceptions.SkipEnvironment(f"Skipping Tox provisioning env '{venv.name}'")
from tox_poetry_installer import _poetry
try:
return _poetry.Factory().create_poetry(venv.core["tox_root"])
# Support running the plugin when the current tox project does not use Poetry for its
# environment/dependency management.
#
# ``RuntimeError`` is dangerous to blindly catch because it can be (and in Poetry's case,
# is) raised in many different places for different purposes.
except RuntimeError as err:
raise exceptions.SkipEnvironment(
f"Skipping installation of locked dependencies due to a Poetry error: {err}"
) from None
def identify_transients(
dep_name: str,
packages: PackageMap,
venv: "_poetry.VirtualEnv",
allow_missing: Sequence[str] = (),
) -> List[PoetryPackage]:
"""Using a pool of packages, identify all transient dependencies of a given package name
:param dep_name: Either the Poetry dependency or the dependency's bare package name to recursively
identify the transient dependencies of
:param packages: All packages from the lockfile to use for identifying dependency relationships.
:param venv: Poetry virtual environment to use for package compatibility checks
:param allow_missing: Sequence of package names to allow to be missing from the lockfile. Any
packages that are not found in the lockfile but their name appears in this
list will be silently skipped from installation.
:returns: List of packages that need to be installed for the requested dependency.
.. note:: The package corresponding to the dependency specified by the ``dep`` parameter will
be included in the returned list of packages.
"""
searched: Set[str] = set()
def _transients(transient: PoetryDependency) -> List[PoetryPackage]:
searched.add(transient.name)
results: List[PoetryPackage] = []
for option in packages[transient.name]:
if venv.is_valid_for_marker(option.to_dependency().marker):
for requirement in option.requires:
if requirement.name not in searched:
results += _transients(requirement)
logger.debug(f"Including {option} for installation")
results.append(option)
break
else:
logger.debug(
f"Skipping {transient.name}: target python version is {'.'.join([str(item) for item in venv.get_version_info()])} but package requires {transient.marker}"
)
return results
try:
for option in packages[dep_name]:
if venv.is_valid_for_marker(option.to_dependency().marker):
dep = option.to_dependency()
break
else:
logger.warning(
f"Skipping {dep_name}: no locked version found compatible with target python version {'.'.join([str(item) for item in venv.get_version_info()])}"
)
return []
return _transients(dep)
except KeyError as err:
missing = err.args[0]
if missing in allow_missing:
logger.debug(f"Skipping {missing}: package is allowed to be unlocked")
return []
if any(
delimiter in missing for delimiter in constants.PEP508_VERSION_DELIMITERS
):
raise exceptions.LockedDepVersionConflictError(
f"Locked dependency '{missing}' cannot include version specifier"
) from None
raise exceptions.LockedDepNotFoundError(
f"No version of locked dependency '{missing}' found in the project lockfile"
) from None
def find_project_deps(
packages: PackageMap,
venv: "_poetry.VirtualEnv",
poetry: "_poetry.Poetry",
extras: Sequence[str] = (),
) -> List[PoetryPackage]:
"""Find the root project dependencies
Recursively identify the dependencies of the root project package
:param packages: Mapping of all locked package names to their corresponding package object
:param venv: Poetry virtual environment to use for package compatibility checks
:param poetry: Poetry object for the current project
:param extras: Sequence of extra names to include the dependencies of
"""
required_dep_names = [
item.name for item in poetry.package.requires if not item.is_optional()
]
extra_dep_names: List[str] = []
for extra in extras:
logger.info(f"Processing project extra '{extra}'")
try:
extra_dep_names += [item.name for item in poetry.package.extras[extra]]
except KeyError:
raise exceptions.ExtraNotFoundError(
f"Environment specifies project extra '{extra}' which was not found in the lockfile"
) from None
dependencies: List[PoetryPackage] = []
for dep_name in required_dep_names + extra_dep_names:
dependencies += identify_transients(
dep_name.lower(), packages, venv, allow_missing=[poetry.package.name]
)
return dedupe_packages(dependencies)
def find_additional_deps(
packages: PackageMap,
venv: "_poetry.VirtualEnv",
poetry: "_poetry.Poetry",
dep_names: Sequence[str],
) -> List[PoetryPackage]:
"""Find additional dependencies
Recursively identify the dependencies of an arbitrary list of package names
:param packages: Mapping of all locked package names to their corresponding package object
:param venv: Poetry virtual environment to use for package compatibility checks
:param poetry: Poetry object for the current project
:param dep_names: Sequence of additional dependency names to recursively find the transient
dependencies for
"""
dependencies: List[PoetryPackage] = []
for dep_name in dep_names:
dependencies += identify_transients(
dep_name.lower(), packages, venv, allow_missing=[poetry.package.name]
)
return dedupe_packages(dependencies)
def find_group_deps(
group: str,
packages: PackageMap,
venv: "_poetry.VirtualEnv",
poetry: "_poetry.Poetry",
) -> List[PoetryPackage]:
"""Find the dependencies belonging to a dependency group
Recursively identify the Poetry dev dependencies
:param group: Name of the dependency group from the project's ``pyproject.toml``
:param packages: Mapping of all locked package names to their corresponding package object
:param venv: Poetry virtual environment to use for package compatibility checks
:param poetry: Poetry object for the current project
"""
return find_additional_deps(
packages,
venv,
poetry,
poetry.pyproject.data["tool"]["poetry"]
.get("group", {})
.get(group, {})
.get("dependencies", {})
.keys(),
)
def find_dev_deps(
packages: PackageMap, venv: "_poetry.VirtualEnv", poetry: "_poetry.Poetry"
) -> List[PoetryPackage]:
"""Find the dev dependencies
Recursively identify the Poetry dev dependencies
:param packages: Mapping of all locked package names to their corresponding package object
:param venv: Poetry virtual environment to use for package compatibility checks
:param poetry: Poetry object for the current project
"""
dev_group_deps = find_group_deps("dev", packages, venv, poetry)
# Legacy pyproject.toml poetry format:
legacy_dev_group_deps = find_additional_deps(
packages,
venv,
poetry,
poetry.pyproject.data["tool"]["poetry"].get("dev-dependencies", {}).keys(),
)
# Poetry 1.2 unions these two toml sections.
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]:
"""Deduplicates a sequence of PoetryPackages while preserving ordering
Adapted from StackOverflow: https://stackoverflow.com/a/480227
"""
seen: Set[PoetryPackage] = set()
# Make this faster, avoid method lookup below
seen_add = seen.add
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

@ -5,8 +5,11 @@ from tox.config.sets import EnvConfigSet
from tox.plugin import impl from tox.plugin import impl
# pylint: disable=missing-function-docstring
@impl @impl
def tox_add_env_config(env_conf: EnvConfigSet): def tox_add_env_config(
env_conf: EnvConfigSet,
):
env_conf.add_config( env_conf.add_config(
"poetry_dep_groups", "poetry_dep_groups",
of_type=List[str], of_type=List[str],

View File

@ -5,6 +5,7 @@ from tox.plugin import impl
from tox_poetry_installer import constants from tox_poetry_installer import constants
# pylint: disable=missing-function-docstring
@impl @impl
def tox_add_option(parser: ToxParser): def tox_add_option(parser: ToxParser):
parser.add_argument( parser.add_argument(

View File

@ -4,40 +4,26 @@ Loads the local Poetry environment and the corresponding lockfile then pulls the
specified by the Tox environment. Finally these dependencies are installed into the Tox specified by the Tox environment. Finally these dependencies are installed into the Tox
environment using the Poetry ``PipInstaller`` backend. environment using the Poetry ``PipInstaller`` backend.
""" """
import collections
import concurrent.futures
import contextlib
import typing
from datetime import datetime
from itertools import chain from itertools import chain
from pathlib import Path
from typing import Collection
from typing import Dict
from typing import List
from typing import Sequence
from typing import Set
from poetry.core.packages.dependency import Dependency as PoetryDependency
from poetry.core.packages.package import Package as PoetryPackage
from tox.plugin import impl from tox.plugin import impl
from tox.tox_env.api import ToxEnv as ToxVirtualEnv from tox.tox_env.api import ToxEnv as ToxVirtualEnv
from tox.tox_env.package import PackageToxEnv
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
from tox_poetry_installer.hooks._tox_on_install_helpers import build_package_map
if typing.TYPE_CHECKING: from tox_poetry_installer.hooks._tox_on_install_helpers import check_preconditions
from tox_poetry_installer import _poetry 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
PackageMap = Dict[str, List[PoetryPackage]] 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 @impl
def tox_on_install( def tox_on_install(tox_env: ToxVirtualEnv, *args) -> None:
tox_env: ToxVirtualEnv, section: str # pylint: disable=unused-argument
) -> None:
try: try:
poetry = check_preconditions(tox_env) poetry = check_preconditions(tox_env)
except exceptions.SkipEnvironment as err: except exceptions.SkipEnvironment as err:
@ -54,10 +40,16 @@ def tox_on_install(
virtualenv = convert_virtualenv(tox_env) virtualenv = convert_virtualenv(tox_env)
if not poetry.locker.is_fresh(): try:
logger.warning( if not poetry.locker.is_fresh():
f"The Poetry lock file is not up to date with the latest changes in {poetry.file}" 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: try:
if tox_env.conf["require_locked_deps"] and tox_env.conf["deps"].lines(): if tox_env.conf["require_locked_deps"] and tox_env.conf["deps"].lines():
@ -119,322 +111,3 @@ def tox_on_install(
dependencies, dependencies,
tox_env.options.parallel_install_threads, tox_env.options.parallel_install_threads,
) )
def check_preconditions(venv: ToxVirtualEnv) -> "_poetry.Poetry":
"""Check that the local project environment meets expectations"""
# Skip running the plugin for the provisioning environment. The provisioned environment,
# for alternative Tox versions and/or the ``requires`` meta dependencies is specially
# handled by Tox and is out of scope for this plugin. Since one of the ways to install this
# plugin in the first place is via the Tox provisioning environment, it quickly becomes a
# chicken-and-egg problem.
if isinstance(venv, PackageToxEnv):
raise exceptions.SkipEnvironment(f"Skipping Tox provisioning env '{venv.name}'")
from tox_poetry_installer import _poetry
try:
return _poetry.Factory().create_poetry(venv.core["tox_root"])
# Support running the plugin when the current tox project does not use Poetry for its
# environment/dependency management.
#
# ``RuntimeError`` is dangerous to blindly catch because it can be (and in Poetry's case,
# is) raised in many different places for different purposes.
except RuntimeError as err:
raise exceptions.SkipEnvironment(
f"Skipping installation of locked dependencies due to a Poetry error: {err}"
) from None
def identify_transients(
dep_name: str,
packages: PackageMap,
venv: "_poetry.VirtualEnv",
allow_missing: Sequence[str] = (),
) -> List[PoetryPackage]:
"""Using a pool of packages, identify all transient dependencies of a given package name
:param dep_name: Either the Poetry dependency or the dependency's bare package name to recursively
identify the transient dependencies of
:param packages: All packages from the lockfile to use for identifying dependency relationships.
:param venv: Poetry virtual environment to use for package compatibility checks
:param allow_missing: Sequence of package names to allow to be missing from the lockfile. Any
packages that are not found in the lockfile but their name appears in this
list will be silently skipped from installation.
:returns: List of packages that need to be installed for the requested dependency.
.. note:: The package corresponding to the dependency specified by the ``dep`` parameter will
be included in the returned list of packages.
"""
searched: Set[str] = set()
def _transients(transient: PoetryDependency) -> List[PoetryPackage]:
searched.add(transient.name)
results: List[PoetryPackage] = []
for option in packages[transient.name]:
if venv.is_valid_for_marker(option.to_dependency().marker):
for requirement in option.requires:
if requirement.name not in searched:
results += _transients(requirement)
logger.debug(f"Including {option} for installation")
results.append(option)
break
else:
logger.debug(
f"Skipping {transient.name}: target python version is {'.'.join([str(item) for item in venv.get_version_info()])} but package requires {transient.marker}"
)
return results
try:
for option in packages[dep_name]:
if venv.is_valid_for_marker(option.to_dependency().marker):
dep = option.to_dependency()
break
else:
logger.warning(
f"Skipping {dep_name}: no locked version found compatible with target python version {'.'.join([str(item) for item in venv.get_version_info()])}"
)
return []
return _transients(dep)
except KeyError as err:
missing = err.args[0]
if missing in allow_missing:
logger.debug(f"Skipping {missing}: package is allowed to be unlocked")
return []
if any(
delimiter in missing for delimiter in constants.PEP508_VERSION_DELIMITERS
):
raise exceptions.LockedDepVersionConflictError(
f"Locked dependency '{missing}' cannot include version specifier"
) from None
raise exceptions.LockedDepNotFoundError(
f"No version of locked dependency '{missing}' found in the project lockfile"
) from None
def find_project_deps(
packages: PackageMap,
venv: "_poetry.VirtualEnv",
poetry: "_poetry.Poetry",
extras: Sequence[str] = (),
) -> List[PoetryPackage]:
"""Find the root project dependencies
Recursively identify the dependencies of the root project package
:param packages: Mapping of all locked package names to their corresponding package object
:param venv: Poetry virtual environment to use for package compatibility checks
:param poetry: Poetry object for the current project
:param extras: Sequence of extra names to include the dependencies of
"""
required_dep_names = [
item.name for item in poetry.package.requires if not item.is_optional()
]
extra_dep_names: List[str] = []
for extra in extras:
logger.info(f"Processing project extra '{extra}'")
try:
extra_dep_names += [item.name for item in poetry.package.extras[extra]]
except KeyError:
raise exceptions.ExtraNotFoundError(
f"Environment specifies project extra '{extra}' which was not found in the lockfile"
) from None
dependencies: List[PoetryPackage] = []
for dep_name in required_dep_names + extra_dep_names:
dependencies += identify_transients(
dep_name.lower(), packages, venv, allow_missing=[poetry.package.name]
)
return dedupe_packages(dependencies)
def find_additional_deps(
packages: PackageMap,
venv: "_poetry.VirtualEnv",
poetry: "_poetry.Poetry",
dep_names: Sequence[str],
) -> List[PoetryPackage]:
"""Find additional dependencies
Recursively identify the dependencies of an arbitrary list of package names
:param packages: Mapping of all locked package names to their corresponding package object
:param venv: Poetry virtual environment to use for package compatibility checks
:param poetry: Poetry object for the current project
:param dep_names: Sequence of additional dependency names to recursively find the transient
dependencies for
"""
dependencies: List[PoetryPackage] = []
for dep_name in dep_names:
dependencies += identify_transients(
dep_name.lower(), packages, venv, allow_missing=[poetry.package.name]
)
return dedupe_packages(dependencies)
def find_group_deps(
group: str,
packages: PackageMap,
venv: "_poetry.VirtualEnv",
poetry: "_poetry.Poetry",
) -> List[PoetryPackage]:
"""Find the dependencies belonging to a dependency group
Recursively identify the Poetry dev dependencies
:param group: Name of the dependency group from the project's ``pyproject.toml``
:param packages: Mapping of all locked package names to their corresponding package object
:param venv: Poetry virtual environment to use for package compatibility checks
:param poetry: Poetry object for the current project
"""
return find_additional_deps(
packages,
venv,
poetry,
poetry.pyproject.data["tool"]["poetry"]
.get("group", {})
.get(group, {})
.get("dependencies", {})
.keys(),
)
def find_dev_deps(
packages: PackageMap, venv: "_poetry.VirtualEnv", poetry: "_poetry.Poetry"
) -> List[PoetryPackage]:
"""Find the dev dependencies
Recursively identify the Poetry dev dependencies
:param packages: Mapping of all locked package names to their corresponding package object
:param venv: Poetry virtual environment to use for package compatibility checks
:param poetry: Poetry object for the current project
"""
dev_group_deps = find_group_deps("dev", packages, venv, poetry)
# Legacy pyproject.toml poetry format:
legacy_dev_group_deps = find_additional_deps(
packages,
venv,
poetry,
poetry.pyproject.data["tool"]["poetry"].get("dev-dependencies", {}).keys(),
)
# Poetry 1.2 unions these two toml sections.
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]:
"""Deduplicates a sequence of PoetryPackages while preserving ordering
Adapted from StackOverflow: https://stackoverflow.com/a/480227
"""
seen: Set[PoetryPackage] = set()
# Make this faster, avoid method lookup below
seen_add = seen.add
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