Skip to content

Commit

Permalink
Improve executable selection when current Python is incompatible
Browse files Browse the repository at this point in the history
  • Loading branch information
sdispater committed Oct 17, 2019
1 parent e62f5a1 commit c5b5279
Show file tree
Hide file tree
Showing 9 changed files with 213 additions and 24 deletions.
17 changes: 0 additions & 17 deletions poetry/console/config/application_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,6 @@ def set_env(self, event, event_name, _): # type: (PreHandleEvent, str, _) -> No
poetry = command.poetry

env_manager = EnvManager(poetry)

# Checking compatibility of the current environment with
# the python dependency specified in pyproject.toml
current_env = env_manager.get()
supported_python = poetry.package.python_constraint
current_python = parse_constraint(
".".join(str(v) for v in current_env.version_info[:3])
)

if not supported_python.allows(current_python):
raise RuntimeError(
"The current Python version ({}) is not supported by the project ({})\n"
"Please activate a compatible Python version.".format(
current_python, poetry.package.python_versions
)
)

env = env_manager.create_venv(io)

if env.is_venv() and io.is_verbose():
Expand Down
2 changes: 1 addition & 1 deletion poetry/packages/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

class Package(object):

AVAILABLE_PYTHONS = {"2", "2.7", "3", "3.4", "3.5", "3.6", "3.7"}
AVAILABLE_PYTHONS = {"2", "2.7", "3", "3.4", "3.5", "3.6", "3.7", "3.8"}

def __init__(self, name, version, pretty_version=None):
"""
Expand Down
97 changes: 97 additions & 0 deletions poetry/utils/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from poetry.locations import CACHE_DIR
from poetry.poetry import Poetry
from poetry.semver import parse_constraint
from poetry.semver.version import Version
from poetry.utils._compat import CalledProcessError
from poetry.utils._compat import Path
Expand Down Expand Up @@ -127,6 +128,26 @@ def __init__(self, e, input=None): # type: (CalledProcessError) -> None
super(EnvCommandError, self).__init__(message)


class NoCompatiblePythonVersionFound(EnvError):
def __init__(self, expected, given=None):
if given:
message = (
"The specified Python version ({}) "
"is not supported by the project ({}).\n"
"Please choose a compatible version "
"or loosen the python constraint specified "
"in the pyproject.toml file.".format(given, expected)
)
else:
message = (
"Poetry was unable to find a compatible version. "
"If you have one, you can explicitly use it "
'via the "env use" command.'
)

super(NoCompatiblePythonVersionFound, self).__init__(message)


class EnvManager(object):
"""
Environments manager
Expand Down Expand Up @@ -439,6 +460,7 @@ def create_venv(
if not name:
name = self._poetry.package.name

python_patch = ".".join([str(v) for v in sys.version_info[:3]])
python_minor = ".".join([str(v) for v in sys.version_info[:2]])
if executable:
python_minor = decode(
Expand All @@ -451,9 +473,84 @@ def create_venv(
]
),
shell=True,
).strip()
)

supported_python = self._poetry.package.python_constraint
if not supported_python.allows(Version.parse(python_minor)):
# The currently activated or chosen Python version
# is not compatible with the Python constraint specified
# for the project.
# If an executable has been specified, we stop there
# and notify the user of the incompatibility.
# Otherwise, we try to find a compatible Python version.
if executable:
raise NoCompatiblePythonVersionFound(
self._poetry.package.python_versions, python_minor
)

io.write_line(
"<warning>The currently activated Python version {} "
"is not supported by the project ({}).\n"
"Trying to find and use a compatible version.</warning> ".format(
python_patch, self._poetry.package.python_versions
)
)

for python_to_try in reversed(
sorted(
self._poetry.package.AVAILABLE_PYTHONS,
key=lambda v: (v.startswith("3"), -len(v), v),
)
):
if len(python_to_try) == 1:
if not parse_constraint("^{}.0".format(python_to_try)).allows_any(
supported_python
):
continue
elif not supported_python.allows_all(
parse_constraint(python_to_try + ".*")
):
continue

python = "python" + python_to_try

if io.is_debug():
io.write_line("<debug>Trying {}</debug>".format(python))

try:
python_patch = decode(
subprocess.check_output(
" ".join(
[
python,
"-c",
"\"import sys; print('.'.join([str(s) for s in sys.version_info[:3]]))\"",
]
),
stderr=subprocess.STDOUT,
shell=True,
).strip()
)
except CalledProcessError:
continue

if not python_patch:
continue

if supported_python.allows(Version.parse(python_patch)):
io.write_line(
"Using <info>{}</info> ({})".format(python, python_patch)
)
executable = python
python_minor = ".".join(python_patch.split(".")[:2])
break

if not executable:
raise NoCompatiblePythonVersionFound(
self._poetry.package.python_versions
)

if root_venv:
venv = venv_path
else:
Expand Down
12 changes: 6 additions & 6 deletions poetry/utils/helpers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import collections
import os
import re
import shutil
import stat
import tempfile

try:
from collections.abc import Mapping
except ImportError:
from collections import Mapping

from contextlib import contextmanager
from typing import List
from typing import Optional
Expand Down Expand Up @@ -144,11 +148,7 @@ def safe_rmtree(path):

def merge_dicts(d1, d2):
for k, v in d2.items():
if (
k in d1
and isinstance(d1[k], dict)
and isinstance(d2[k], collections.Mapping)
):
if k in d1 and isinstance(d1[k], dict) and isinstance(d2[k], Mapping):
merge_dicts(d1[k], d2[k])
else:
d1[k] = d2[k]
1 change: 1 addition & 0 deletions tests/masonry/builders/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ def test_get_metadata_content():
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Topic :: Software Development :: Build Tools",
"Topic :: Software Development :: Libraries :: Python Modules",
]
Expand Down
2 changes: 2 additions & 0 deletions tests/masonry/builders/test_complete.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ def test_complete():
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Provides-Extra: time
Expand Down Expand Up @@ -318,6 +319,7 @@ def test_complete_no_vcs():
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Provides-Extra: time
Expand Down
1 change: 1 addition & 0 deletions tests/masonry/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def test_prepare_metadata_for_build_wheel():
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Provides-Extra: time
Expand Down
1 change: 1 addition & 0 deletions tests/test_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ def test_create_poetry():
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Topic :: Software Development :: Build Tools",
"Topic :: Software Development :: Libraries :: Python Modules",
]
Expand Down
104 changes: 104 additions & 0 deletions tests/utils/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from poetry.utils._compat import Path
from poetry.utils.env import EnvManager
from poetry.utils.env import EnvCommandError
from poetry.utils.env import NoCompatiblePythonVersionFound
from poetry.utils.env import VirtualEnv
from poetry.utils.toml_file import TomlFile

Expand Down Expand Up @@ -554,3 +555,106 @@ def test_run_with_input_non_zero_return(tmp_dir, tmp_venv):
tmp_venv.run("python", "-", input_=ERRORING_SCRIPT)

assert processError.value.e.returncode == 1


def test_create_venv_tries_to_find_a_compatible_python_executable_using_generic_ones_first(
manager, poetry, config, mocker
):
if "VIRTUAL_ENV" in os.environ:
del os.environ["VIRTUAL_ENV"]

poetry.package.python_versions = "^3.6"
venv_name = manager.generate_env_name("simple-project", str(poetry.file.parent))

mocker.patch("sys.version_info", (2, 7, 16))
mocker.patch(
"poetry.utils._compat.subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.7.5")),
)
m = mocker.patch(
"poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: ""
)

manager.create_venv(NullIO())

m.assert_called_with(
"/foo/virtualenvs/{}-py3.7".format(venv_name), executable="python3"
)


def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific_ones(
manager, poetry, config, mocker
):
if "VIRTUAL_ENV" in os.environ:
del os.environ["VIRTUAL_ENV"]

poetry.package.python_versions = "^3.6"
venv_name = manager.generate_env_name("simple-project", str(poetry.file.parent))

mocker.patch("sys.version_info", (2, 7, 16))
mocker.patch(
"poetry.utils._compat.subprocess.check_output", side_effect=["3.5.3", "3.8.0"]
)
m = mocker.patch(
"poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: ""
)

manager.create_venv(NullIO())

m.assert_called_with(
"/foo/virtualenvs/{}-py3.8".format(venv_name), executable="python3.8"
)


def test_create_venv_fails_if_no_compatible_python_version_could_be_found(
manager, poetry, config, mocker
):
if "VIRTUAL_ENV" in os.environ:
del os.environ["VIRTUAL_ENV"]

poetry.package.python_versions = "^4.8"

mocker.patch(
"poetry.utils._compat.subprocess.check_output", side_effect=["", "", "", ""]
)
m = mocker.patch(
"poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: ""
)

with pytest.raises(NoCompatiblePythonVersionFound) as e:
manager.create_venv(NullIO())

expected_message = (
"Poetry was unable to find a compatible version. "
"If you have one, you can explicitly use it "
'via the "env use" command.'
)

assert expected_message == str(e.value)
assert 0 == m.call_count


def test_create_venv_does_not_try_to_find_compatible_versions_with_executable(
manager, poetry, config, mocker
):
if "VIRTUAL_ENV" in os.environ:
del os.environ["VIRTUAL_ENV"]

poetry.package.python_versions = "^4.8"

mocker.patch("poetry.utils._compat.subprocess.check_output", side_effect=["3.8.0"])
m = mocker.patch(
"poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: ""
)

with pytest.raises(NoCompatiblePythonVersionFound) as e:
manager.create_venv(NullIO(), executable="3.8")

expected_message = (
"The specified Python version (3.8.0) is not supported by the project (^4.8).\n"
"Please choose a compatible version or loosen the python constraint "
"specified in the pyproject.toml file."
)

assert expected_message == str(e.value)
assert 0 == m.call_count

0 comments on commit c5b5279

Please sign in to comment.