Compare commits

...

5 Commits

Author SHA1 Message Date
1d3b7834c6
Replace optional [poetry] extra with explicit poetry dependencies
Fixes #79
2024-08-13 13:53:51 -04:00
a270261e6c
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-13 13:44:25 -04:00
fdaee670c4
Add import exception to Poetry import error message
Fixes #96
2024-08-13 13:14:21 -04:00
62f90de297
Reorganize hooks into dedicated submodule
Fixes #93
2024-08-13 13:06:12 -04:00
f853a4b0b7
Remove unsafe dependency check
Fixes #97
2024-08-13 12:54:28 -04:00
12 changed files with 297 additions and 353 deletions

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. |
| `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. |
| `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
> of sync with the Poetry lockfile. If this is the case, than a warning will be logged

73
poetry.lock generated
View File

@ -108,7 +108,7 @@ files = [
name = "backports-cached-property"
version = "1.0.2"
description = "cached_property() - computed once per instance, cached as attribute"
optional = true
optional = false
python-versions = ">=3.6.0"
files = [
{file = "backports.cached-property-1.0.2.tar.gz", hash = "sha256:9306f9eed6ec55fd156ace6bc1094e2c86fae5fb2bf07b6a9c00745c656e75dd"},
@ -192,7 +192,7 @@ black = ">=22.1.0"
name = "build"
version = "0.10.0"
description = "A simple, correct Python build frontend"
optional = true
optional = false
python-versions = ">= 3.7"
files = [
{file = "build-0.10.0-py3-none-any.whl", hash = "sha256:af266720050a66c893a6096a2f410989eeac74ff9a68ba194b3f6473e8e26171"},
@ -216,7 +216,7 @@ virtualenv = ["virtualenv (>=20.0.35)"]
name = "cachecontrol"
version = "0.12.14"
description = "httplib2 caching for requests"
optional = true
optional = false
python-versions = ">=3.6"
files = [
{file = "CacheControl-0.12.14-py2.py3-none-any.whl", hash = "sha256:1c2939be362a70c4e5f02c6249462b3b7a24441e4f1ced5e9ef028172edf356a"},
@ -269,7 +269,7 @@ files = [
name = "cffi"
version = "1.15.1"
description = "Foreign Function Interface for Python calling C code."
optional = true
optional = false
python-versions = "*"
files = [
{file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"},
@ -466,7 +466,7 @@ files = [
name = "cleo"
version = "2.1.0"
description = "Cleo allows you to create beautiful and testable command-line interfaces."
optional = true
optional = false
python-versions = ">=3.7,<4.0"
files = [
{file = "cleo-2.1.0-py3-none-any.whl", hash = "sha256:4a31bd4dd45695a64ee3c4758f583f134267c2bc518d8ae9a29cf237d009b07e"},
@ -579,7 +579,7 @@ toml = ["tomli"]
name = "crashtest"
version = "0.4.1"
description = "Manage Python errors with ease"
optional = true
optional = false
python-versions = ">=3.7,<4.0"
files = [
{file = "crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5"},
@ -590,7 +590,7 @@ files = [
name = "cryptography"
version = "43.0.0"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"},
@ -694,7 +694,7 @@ pipenv = ["pipenv (<=2022.12.19)"]
name = "dulwich"
version = "0.21.7"
description = "Python Git Library"
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "dulwich-0.21.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d4c0110798099bb7d36a110090f2688050703065448895c4f53ade808d889dd3"},
@ -844,7 +844,7 @@ test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit",
name = "html5lib"
version = "1.1"
description = "HTML parser based on the WHATWG HTML specification"
optional = true
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
files = [
{file = "html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d"},
@ -910,7 +910,7 @@ testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs
name = "importlib-resources"
version = "5.12.0"
description = "Read resources from Python packages"
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "importlib_resources-5.12.0-py3-none-any.whl", hash = "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a"},
@ -939,7 +939,7 @@ files = [
name = "installer"
version = "0.7.0"
description = "A library for installing Python wheels."
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "installer-0.7.0-py3-none-any.whl", hash = "sha256:05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53"},
@ -1006,7 +1006,7 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"]
name = "jaraco-classes"
version = "3.2.3"
description = "Utility functions for Python class constructs"
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "jaraco.classes-3.2.3-py3-none-any.whl", hash = "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158"},
@ -1043,7 +1043,7 @@ testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"]
name = "jeepney"
version = "0.8.0"
description = "Low-level, pure Python DBus protocol wrapper."
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"},
@ -1058,7 +1058,7 @@ trio = ["async_generator", "trio"]
name = "jsonschema"
version = "4.17.3"
description = "An implementation of JSON Schema validation for Python"
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "jsonschema-4.17.3-py3-none-any.whl", hash = "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6"},
@ -1081,7 +1081,7 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-
name = "keyring"
version = "23.13.1"
description = "Store and access your passwords safely."
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "keyring-23.13.1-py3-none-any.whl", hash = "sha256:771ed2a91909389ed6148631de678f82ddc73737d85a927f382a8a1b157898cd"},
@ -1170,7 +1170,7 @@ test = ["coverage", "pytest", "pytest-cov"]
name = "lockfile"
version = "0.12.2"
description = "Platform-independent file locking module"
optional = true
optional = false
python-versions = "*"
files = [
{file = "lockfile-0.12.2-py2.py3-none-any.whl", hash = "sha256:6c3cb24f344923d30b2785d5ad75182c8ea7ac1b6171b08657258ec7429d50fa"},
@ -1313,7 +1313,7 @@ files = [
name = "more-itertools"
version = "9.1.0"
description = "More routines for operating on iterables, beyond itertools"
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "more-itertools-9.1.0.tar.gz", hash = "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d"},
@ -1324,7 +1324,7 @@ files = [
name = "msgpack"
version = "1.0.5"
description = "MessagePack serializer"
optional = true
optional = false
python-versions = "*"
files = [
{file = "msgpack-1.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:525228efd79bb831cf6830a732e2e80bc1b05436b086d4264814b4b2955b2fa9"},
@ -1530,7 +1530,7 @@ files = [
name = "pkginfo"
version = "1.10.0"
description = "Query metadata from sdists / bdists / installed packages."
optional = true
optional = false
python-versions = ">=3.6"
files = [
{file = "pkginfo-1.10.0-py3-none-any.whl", hash = "sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097"},
@ -1544,7 +1544,7 @@ testing = ["pytest", "pytest-cov", "wheel"]
name = "pkgutil-resolve-name"
version = "1.3.10"
description = "Resolve a name to an object."
optional = true
optional = false
python-versions = ">=3.6"
files = [
{file = "pkgutil_resolve_name-1.3.10-py3-none-any.whl", hash = "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e"},
@ -1591,7 +1591,7 @@ testing = ["pytest", "pytest-benchmark"]
name = "poetry"
version = "1.5.1"
description = "Python dependency management and packaging made easy."
optional = true
optional = false
python-versions = ">=3.7,<4.0"
files = [
{file = "poetry-1.5.1-py3-none-any.whl", hash = "sha256:dfc7ce3a38ae216c0465694e2e674bef6eb1a2ba81aa47a26f9dc03362fe2f5f"},
@ -1647,7 +1647,7 @@ importlib-metadata = {version = ">=1.7.0", markers = "python_version < \"3.8\""}
name = "poetry-plugin-export"
version = "1.4.0"
description = "Poetry plugin to export the dependencies to various formats"
optional = true
optional = false
python-versions = ">=3.7,<4.0"
files = [
{file = "poetry_plugin_export-1.4.0-py3-none-any.whl", hash = "sha256:5d9186d6f77cf2bf35fc96bd11fe650cc7656e515b17d99cb65018d50ba22589"},
@ -1746,7 +1746,7 @@ files = [
name = "pycparser"
version = "2.21"
description = "C parser in Python"
optional = true
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
@ -1815,7 +1815,7 @@ testing = ["covdefaults (>=2.3)", "importlib-metadata (>=6.6)", "pytest (>=7.3.1
name = "pyproject-hooks"
version = "1.1.0"
description = "Wrappers to call pyproject.toml-based build backend hooks."
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "pyproject_hooks-1.1.0-py3-none-any.whl", hash = "sha256:7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2"},
@ -1826,7 +1826,7 @@ files = [
name = "pyrsistent"
version = "0.19.3"
description = "Persistent/Functional/Immutable data structures"
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "pyrsistent-0.19.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:20460ac0ea439a3e79caa1dbd560344b64ed75e85d8703943e0b66c2a6150e4a"},
@ -1906,7 +1906,7 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale
name = "pywin32-ctypes"
version = "0.2.2"
description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
optional = true
optional = false
python-versions = ">=3.6"
files = [
{file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"},
@ -1977,7 +1977,7 @@ files = [
name = "rapidfuzz"
version = "3.4.0"
description = "rapid fuzzy string matching"
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "rapidfuzz-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1438e68fe8869fe6819a313140e98641b34bfc89234b82486d8fd02044a067e8"},
@ -2132,7 +2132,7 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
name = "requests-toolbelt"
version = "1.0.0"
description = "A utility belt for advanced users of python-requests"
optional = true
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
{file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"},
@ -2265,7 +2265,7 @@ gitlab = ["python-gitlab (>=1.3.0)"]
name = "secretstorage"
version = "3.3.3"
description = "Python bindings to FreeDesktop.org Secret Service API"
optional = true
optional = false
python-versions = ">=3.6"
files = [
{file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"},
@ -2296,7 +2296,7 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (
name = "shellingham"
version = "1.5.4"
description = "Tool to Detect Surrounding Shell"
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
@ -2385,7 +2385,7 @@ files = [
name = "tomlkit"
version = "0.12.5"
description = "Style preserving TOML library"
optional = true
optional = false
python-versions = ">=3.7"
files = [
{file = "tomlkit-0.12.5-py3-none-any.whl", hash = "sha256:af914f5a9c59ed9d0762c7b64d3b5d5df007448eb9cd2edc8a46b1eafead172f"},
@ -2440,7 +2440,7 @@ test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,
name = "trove-classifiers"
version = "2024.7.2"
description = "Canonical source for classifiers on PyPI (pypi.org)."
optional = true
optional = false
python-versions = "*"
files = [
{file = "trove_classifiers-2024.7.2-py3-none-any.whl", hash = "sha256:ccc57a33717644df4daca018e7ec3ef57a835c48e96a1e71fc07eb7edac67af6"},
@ -2585,7 +2585,7 @@ files = [
name = "webencodings"
version = "0.5.1"
description = "Character encoding aliases for legacy web content"
optional = true
optional = false
python-versions = "*"
files = [
{file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
@ -2675,7 +2675,7 @@ files = [
name = "xattr"
version = "0.10.1"
description = "Python wrapper for extended filesystem attributes"
optional = true
optional = false
python-versions = "*"
files = [
{file = "xattr-0.10.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:16a660a883e703b311d1bbbcafc74fa877585ec081cd96e8dd9302c028408ab1"},
@ -2770,10 +2770,7 @@ files = [
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
[extras]
poetry = ["cleo", "poetry"]
[metadata]
lock-version = "2.0"
python-versions = "^3.7"
content-hash = "eb94af731fc7a03914b50c82819a5a766687fbd75274a8c676ea9e5c9f416308"
content-hash = "1341827907d7d185c4ff0ad557de689251f0aa50de6ec46c42c815c32a8c2d1b"

View File

@ -34,13 +34,10 @@ classifiers = [
[tool.poetry.plugins.tox]
poetry_installer = "tox_poetry_installer"
[tool.poetry.extras]
poetry = ["poetry", "cleo"]
[tool.poetry.dependencies]
python = "^3.7"
cleo = {version = ">=1.0,<3.0", optional = true}
poetry = {version = "^1.5.0", optional = true}
cleo = ">=1.0,<3.0"
poetry = "^1.5.0"
poetry-core = "^1.1.0"
tox = "^4"

View File

@ -37,7 +37,7 @@ try:
from poetry.installation.operations.install import Install
from poetry.poetry import Poetry
from poetry.utils.env import VirtualEnv
except ImportError:
except ImportError as err:
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

View File

@ -19,9 +19,5 @@ PEP508_VERSION_DELIMITERS: Tuple[str, ...] = ("~=", "==", "!=", ">", "<")
# console output.
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
DEFAULT_INSTALL_THREADS: int = 10

View File

@ -11,7 +11,6 @@ All exceptions should inherit from the common base exception :exc:`ToxPoetryInst
+-- LockedDepNotFoundError
+-- ExtraNotFoundError
+-- LockedDepsRequiredError
+-- RequiresUnsafeDepError
"""
@ -42,7 +41,3 @@ class ExtraNotFoundError(ToxPoetryInstallerException):
class LockedDepsRequiredError(ToxPoetryInstallerException):
"""Environment cannot specify unlocked dependencies when locked dependencies are required"""
class RequiresUnsafeDepError(ToxPoetryInstallerException):
"""Package under test depends on an unsafe dependency and cannot be installed"""

View File

@ -1,168 +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(
"--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

@ -0,0 +1,43 @@
"""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
@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,16 @@
"""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
@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

@ -1,10 +1,17 @@
"""Helper utility functions, usually bridging Tox and Poetry functionality"""
# 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
"""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
@ -12,6 +19,7 @@ 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
@ -26,6 +34,93 @@ if typing.TYPE_CHECKING:
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"""
@ -46,36 +141,12 @@ def check_preconditions(venv: ToxVirtualEnv) -> "_poetry.Poetry":
#
# ``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:
except RuntimeError as err:
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
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(
dep_name: str,
packages: PackageMap,
@ -132,13 +203,6 @@ def identify_transients(
except KeyError as err:
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:
logger.debug(f"Skipping {missing}: package is allowed to be unlocked")
return []
@ -171,11 +235,6 @@ def find_project_deps(
: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 = [
item.name for item in poetry.package.requires if not item.is_optional()
]
@ -276,6 +335,76 @@ def find_dev_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]:
"""Deduplicates a sequence of PoetryPackages while preserving ordering
@ -285,3 +414,27 @@ def dedupe_packages(packages: Sequence[PoetryPackage]) -> List[PoetryPackage]:
# 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

@ -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()