mirror of
https://github.com/enpaul/tox-poetry-installer.git
synced 2024-10-29 19:47:00 +00:00
Ethan Paul
a270261e6c
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.
441 lines
16 KiB
Python
441 lines
16 KiB
Python
"""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.
|
|
"""
|
|
import collections
|
|
import concurrent.futures
|
|
import contextlib
|
|
import typing
|
|
from datetime import datetime
|
|
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.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
|
|
|
|
|
|
PackageMap = Dict[str, List[PoetryPackage]]
|
|
|
|
|
|
@impl
|
|
def tox_on_install(
|
|
tox_env: ToxVirtualEnv, section: str # pylint: disable=unused-argument
|
|
) -> 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)
|
|
|
|
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 = 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,
|
|
)
|
|
|
|
|
|
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
|