diff --git a/tox_poetry_installer/hooks/tox_on_install.py b/tox_poetry_installer/hooks/tox_on_install.py index cf13a94..6cafcc8 100644 --- a/tox_poetry_installer/hooks/tox_on_install.py +++ b/tox_poetry_installer/hooks/tox_on_install.py @@ -4,15 +4,34 @@ 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 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 installer from tox_poetry_installer import logger -from tox_poetry_installer import utilities + +if typing.TYPE_CHECKING: + from tox_poetry_installer import _poetry + + +PackageMap = Dict[str, List[PoetryPackage]] @impl @@ -20,7 +39,7 @@ def tox_on_install( tox_env: ToxVirtualEnv, section: str # pylint: disable=unused-argument ) -> None: try: - poetry = utilities.check_preconditions(tox_env) + poetry = check_preconditions(tox_env) except exceptions.SkipEnvironment as err: if ( isinstance(err, exceptions.PoetryNotInstalledError) @@ -33,7 +52,7 @@ def tox_on_install( logger.info(f"Loaded project pyproject.toml from {poetry.file}") - virtualenv = utilities.convert_virtualenv(tox_env) + virtualenv = convert_virtualenv(tox_env) if not poetry.locker.is_fresh(): logger.warning( @@ -46,13 +65,13 @@ def tox_on_install( f"Unlocked dependencies '{tox_env.conf['deps']}' specified for environment '{tox_env.name}' which requires locked dependencies" ) - packages = utilities.build_package_map(poetry) + packages = build_package_map(poetry) - group_deps = utilities.dedupe_packages( + group_deps = dedupe_packages( list( chain( *[ - utilities.find_group_deps(group, packages, virtualenv, poetry) + find_group_deps(group, packages, virtualenv, poetry) for group in tox_env.conf["poetry_dep_groups"] ] ) @@ -62,7 +81,7 @@ def tox_on_install( f"Identified {len(group_deps)} group dependencies to install to env" ) - env_deps = utilities.find_additional_deps( + env_deps = find_additional_deps( packages, virtualenv, poetry, tox_env.conf["locked_deps"] ) @@ -77,9 +96,7 @@ def tox_on_install( extras = [] if tox_env.conf["install_project_deps"]: - project_deps = utilities.find_project_deps( - packages, virtualenv, poetry, extras - ) + project_deps = find_project_deps(packages, virtualenv, poetry, extras) logger.info( f"Identified {len(project_deps)} project dependencies to install to env" ) @@ -93,12 +110,331 @@ def tox_on_install( logger.error(f"Internal plugin error: {err}") raise err - dependencies = utilities.dedupe_packages(group_deps + env_deps + project_deps) + dependencies = dedupe_packages(group_deps + env_deps + project_deps) logger.info(f"Installing {len(dependencies)} dependencies from Poetry lock file") - installer.install( + 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 diff --git a/tox_poetry_installer/installer.py b/tox_poetry_installer/installer.py deleted file mode 100644 index 60d3a7b..0000000 --- a/tox_poetry_installer/installer.py +++ /dev/null @@ -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() diff --git a/tox_poetry_installer/utilities.py b/tox_poetry_installer/utilities.py deleted file mode 100644 index 2bfeda8..0000000 --- a/tox_poetry_installer/utilities.py +++ /dev/null @@ -1,275 +0,0 @@ -"""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 -import collections -import typing -from pathlib import Path -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 - - -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 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, - 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 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))]