Commit c0fffa96 by Sébastien Eustace Committed by GitHub

Merge pull request #4084 from python-poetry/improve-self-update

Improve the `self update` command to account for the new installer
parents 9591e884 c02703ee
......@@ -72,7 +72,7 @@ You can specify a package in the following forms:
plugins = self.argument("plugins")
# Plugins should be installed in the system env to be globally available
system_env = EnvManager.get_system_env()
system_env = EnvManager.get_system_env(naive=True)
env_dir = Path(
os.getenv("POETRY_HOME") if os.getenv("POETRY_HOME") else system_env.path
......
......@@ -43,7 +43,7 @@ class PluginRemoveCommand(Command):
plugins = self.argument("plugins")
system_env = EnvManager.get_system_env()
system_env = EnvManager.get_system_env(naive=True)
env_dir = Path(
os.getenv("POETRY_HOME") if os.getenv("POETRY_HOME") else system_env.path
)
......
......@@ -38,7 +38,7 @@ class PluginShowCommand(Command):
+ PluginManager("plugin").get_plugin_entry_points()
)
system_env = EnvManager.get_system_env()
system_env = EnvManager.get_system_env(naive=True)
installed_repository = InstalledRepository.load(
system_env, with_dependencies=True
)
......
from __future__ import unicode_literals
import hashlib
import os
import re
import shutil
import stat
import subprocess
import sys
import tarfile
import site
from functools import cmp_to_key
from gzip import GzipFile
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from urllib.error import HTTPError
from urllib.request import urlopen
from cleo.helpers import argument
from cleo.helpers import option
......@@ -26,27 +15,7 @@ from ..command import Command
if TYPE_CHECKING:
from poetry.core.packages.package import Package
from poetry.core.semver.version import Version
BIN = """# -*- coding: utf-8 -*-
import glob
import sys
import os
lib = os.path.normpath(os.path.join(os.path.realpath(__file__), "../..", "lib"))
vendors = os.path.join(lib, "poetry", "_vendor")
current_vendors = os.path.join(
vendors, "py{}".format(".".join(str(v) for v in sys.version_info[:2]))
)
sys.path.insert(0, lib)
sys.path.insert(0, current_vendors)
if __name__ == "__main__":
from poetry.console import main
main()
"""
BAT = '@echo off\r\n{python_executable} "{poetry_bin}" %*\r\n'
from poetry.repositories.pool import Pool
class SelfUpdateCommand(Command):
......@@ -55,48 +24,81 @@ class SelfUpdateCommand(Command):
description = "Updates Poetry to the latest version."
arguments = [argument("version", "The version to update to.", optional=True)]
options = [option("preview", None, "Install prereleases.")]
REPOSITORY_URL = "https://github.com/python-poetry/poetry"
BASE_URL = REPOSITORY_URL + "/releases/download"
options = [
option("preview", None, "Allow the installation of pre-release versions."),
option(
"dry-run",
None,
"Output the operations but do not execute anything "
"(implicitly enables --verbose).",
),
]
_data_dir = None
_bin_dir = None
_pool = None
@property
def home(self) -> Path:
from pathlib import Path
def data_dir(self) -> Path:
if self._data_dir is not None:
return self._data_dir
return Path(os.environ.get("POETRY_HOME", "~/.poetry")).expanduser()
from poetry.locations import data_dir
@property
def bin(self) -> Path:
return self.home / "bin"
self._data_dir = data_dir()
return self._data_dir
@property
def lib(self) -> Path:
return self.home / "lib"
def bin_dir(self) -> Path:
if self._data_dir is not None:
return self._data_dir
from poetry.utils._compat import WINDOWS
if os.getenv("POETRY_HOME"):
return Path(os.getenv("POETRY_HOME"), "bin").expanduser()
user_base = site.getuserbase()
if WINDOWS:
bin_dir = os.path.join(user_base, "Scripts")
else:
bin_dir = os.path.join(user_base, "bin")
self._bin_dir = Path(bin_dir)
return self._bin_dir
@property
def lib_backup(self) -> Path:
return self.home / "lib-backup"
def pool(self) -> "Pool":
if self._pool is not None:
return self._pool
from poetry.repositories.pool import Pool
from poetry.repositories.pypi_repository import PyPiRepository
pool = Pool()
pool.add_repository(PyPiRepository())
def handle(self) -> None:
return pool
def handle(self) -> int:
from poetry.__version__ import __version__
from poetry.core.packages.dependency import Dependency
from poetry.core.semver.version import Version
from poetry.repositories.pypi_repository import PyPiRepository
self._check_recommended_installation()
version = self.argument("version")
if not version:
version = ">=" + __version__
repo = PyPiRepository(fallback=False)
repo = self.pool.repositories[0]
packages = repo.find_packages(
Dependency("poetry", version, allows_prereleases=self.option("preview"))
)
if not packages:
self.line("No release found for the specified version")
return
return 1
packages.sort(
key=cmp_to_key(
......@@ -122,205 +124,101 @@ class SelfUpdateCommand(Command):
if release is None:
self.line("No new release found")
return
return 1
if release.version == Version.parse(__version__):
self.line("You are using the latest version")
return
self.update(release)
def update(self, release: "Package") -> None:
version = release.version
self.line("Updating to <info>{}</info>".format(version))
if self.lib_backup.exists():
shutil.rmtree(str(self.lib_backup))
return 0
# Backup the current installation
if self.lib.exists():
shutil.copytree(str(self.lib), str(self.lib_backup))
shutil.rmtree(str(self.lib))
try:
self._update(version)
except Exception:
if not self.lib_backup.exists():
raise
shutil.copytree(str(self.lib_backup), str(self.lib))
shutil.rmtree(str(self.lib_backup))
raise
finally:
if self.lib_backup.exists():
shutil.rmtree(str(self.lib_backup))
self.line("Updating <c1>Poetry</c1> to <c2>{}</c2>".format(release.version))
self.line("")
self.make_bin()
self.update(release)
self.line("")
self.line("")
self.line(
"<info>Poetry</info> (<comment>{}</comment>) is installed now. Great!".format(
version
"<c1>Poetry</c1> (<c2>{}</c2>) is installed now. Great!".format(
release.version
)
)
def _update(self, version: "Version") -> None:
from poetry.utils.helpers import temporary_directory
return 0
release_name = self._get_release_name(version)
checksum = "{}.sha256sum".format(release_name)
base_url = self.BASE_URL
try:
r = urlopen(base_url + "/{}/{}".format(version, checksum))
except HTTPError as e:
if e.code == 404:
raise RuntimeError("Could not find {} file".format(checksum))
def update(self, release: "Package") -> None:
from poetry.utils.env import EnvManager
raise
version = release.version
checksum = r.read().decode().strip()
env = EnvManager.get_system_env(naive=True)
# We get the payload from the remote host
name = "{}.tar.gz".format(release_name)
try:
r = urlopen(base_url + "/{}/{}".format(version, name))
except HTTPError as e:
if e.code == 404:
raise RuntimeError("Could not find {} file".format(name))
raise
meta = r.info()
size = int(meta["Content-Length"])
current = 0
block_size = 8192
bar = self.progress_bar(max=size)
bar.set_format(" - Downloading <info>{}</> <comment>%percent%%</>".format(name))
bar.start()
sha = hashlib.sha256()
with temporary_directory(prefix="poetry-updater-") as dir_:
tar = os.path.join(dir_, name)
with open(tar, "wb") as f:
while True:
buffer = r.read(block_size)
if not buffer:
break
current += len(buffer)
f.write(buffer)
sha.update(buffer)
bar.set_progress(current)
bar.finish()
# Checking hashes
if checksum != sha.hexdigest():
raise RuntimeError(
"Hashes for {} do not match: {} != {}".format(
name, checksum, sha.hexdigest()
)
)
gz = GzipFile(tar, mode="rb")
try:
with tarfile.TarFile(tar, fileobj=gz, format=tarfile.PAX_FORMAT) as f:
f.extractall(str(self.lib))
finally:
gz.close()
def process(self, *args: Any) -> str:
return subprocess.check_output(list(args), stderr=subprocess.STDOUT)
def _check_recommended_installation(self) -> None:
from pathlib import Path
from poetry.console.exceptions import PoetrySimpleConsoleException
current = Path(__file__)
# We can't use is_relative_to() since it's only available in Python 3.9+
try:
current.relative_to(self.home)
env.path.relative_to(self.data_dir)
except ValueError:
# Poetry was not installed using the recommended installer
from poetry.console.exceptions import PoetrySimpleConsoleException
raise PoetrySimpleConsoleException(
"Poetry was not installed with the recommended installer, "
"so it cannot be updated automatically."
)
def _get_release_name(self, version: "Version") -> str:
platform = sys.platform
if platform == "linux2":
platform = "linux"
self._update(version)
self._make_bin()
return "poetry-{}-{}".format(version, platform)
def _update(self, version: "Version") -> None:
from poetry.config.config import Config
from poetry.core.packages.dependency import Dependency
from poetry.core.packages.project_package import ProjectPackage
from poetry.installation.installer import Installer
from poetry.packages.locker import NullLocker
from poetry.repositories.installed_repository import InstalledRepository
from poetry.utils.env import EnvManager
env = EnvManager.get_system_env()
installed = InstalledRepository.load(env)
root = ProjectPackage("poetry-updater", "0.0.0")
root.python_versions = ".".join(str(c) for c in env.version_info[:3])
root.add_dependency(Dependency("poetry", version.text))
installer = Installer(
self.io,
env,
root,
NullLocker(self.data_dir.joinpath("poetry.lock"), {}),
self.pool,
Config(),
installed=installed,
)
installer.update(True)
installer.dry_run(self.option("dry-run"))
installer.run()
def make_bin(self) -> None:
def _make_bin(self) -> None:
from poetry.utils._compat import WINDOWS
self.bin.mkdir(0o755, parents=True, exist_ok=True)
python_executable = self._which_python()
self.line("")
self.line("Updating the <c1>poetry</c1> script")
if WINDOWS:
with self.bin.joinpath("poetry.bat").open("w", newline="") as f:
f.write(
BAT.format(
python_executable=python_executable,
poetry_bin=str(self.bin / "poetry").replace(
os.environ["USERPROFILE"], "%USERPROFILE%"
),
)
)
bin_content = BIN
if not WINDOWS:
bin_content = "#!/usr/bin/env {}\n".format(python_executable) + bin_content
self.bin.joinpath("poetry").write_text(bin_content, encoding="utf-8")
if not WINDOWS:
# Making the file executable
st = os.stat(str(self.bin.joinpath("poetry")))
os.chmod(str(self.bin.joinpath("poetry")), st.st_mode | stat.S_IEXEC)
def _which_python(self) -> str:
"""
Decides which python executable we'll embed in the launcher script.
"""
from poetry.utils._compat import WINDOWS
self.bin_dir.mkdir(parents=True, exist_ok=True)
allowed_executables = ["python", "python3"]
script = "poetry"
target_script = "venv/bin/poetry"
if WINDOWS:
allowed_executables += ["py.exe -3", "py.exe -2"]
# \d in regex ensures we can convert to int later
version_matcher = re.compile(r"^Python (?P<major>\d+)\.(?P<minor>\d+)\..+$")
fallback = None
for executable in allowed_executables:
try:
raw_version = subprocess.check_output(
executable + " --version", stderr=subprocess.STDOUT, shell=True
).decode("utf-8")
except subprocess.CalledProcessError:
continue
match = version_matcher.match(raw_version.strip())
if match and tuple(map(int, match.groups())) >= (3, 0):
# favor the first py3 executable we can find.
return executable
if fallback is None:
# keep this one as the fallback; it was the first valid executable we found.
fallback = executable
script = "poetry.exe"
target_script = "venv/Scripts/poetry.exe"
if fallback is None:
# Avoid breaking existing scripts
fallback = "python"
if self.bin_dir.joinpath(script).exists():
self.bin_dir.joinpath(script).unlink()
return fallback
try:
self.bin_dir.joinpath(script).symlink_to(
self.data_dir.joinpath(target_script)
)
except OSError:
# This can happen if the user
# does not have the correct permission on Windows
shutil.copy(
self.data_dir.joinpath(target_script), self.bin_dir.joinpath(script)
)
import os
from pathlib import Path
from .utils.appdirs import user_cache_dir
......@@ -10,3 +12,10 @@ DATA_DIR = user_data_dir("pypoetry")
CONFIG_DIR = user_config_dir("pypoetry")
REPOSITORY_CACHE_DIR = Path(CACHE_DIR) / "cache" / "repositories"
def data_dir() -> Path:
if os.getenv("POETRY_HOME"):
return Path(os.getenv("POETRY_HOME")).expanduser()
return Path(user_data_dir("pypoetry", roaming=True))
......@@ -595,3 +595,8 @@ class Locker:
data["develop"] = package.develop
return data
class NullLocker(Locker):
def set_lock_data(self, root: Package, packages: List[Package]) -> bool:
pass
......@@ -996,8 +996,31 @@ class EnvManager:
shutil.rmtree(str(file_path))
@classmethod
def get_system_env(cls) -> "SystemEnv":
return SystemEnv(Path(sys.prefix), cls.get_base_prefix())
def get_system_env(cls, naive: bool = False) -> "SystemEnv":
"""
Retrieve the current Python environment.
This can be the base Python environment or an activated virtual environment.
This method also workaround the issue that the virtual environment
used by Poetry internally (when installed via the custom installer)
is incorrectly detected as the system environment. Note that this workaround
happens only when `naive` is False since there are times where we actually
want to retrieve Poetry's custom virtual environment
(e.g. plugin installation or self update).
"""
prefix, base_prefix = Path(sys.prefix), cls.get_base_prefix()
if naive is False:
from poetry.locations import data_dir
try:
prefix.relative_to(data_dir())
except ValueError:
pass
else:
prefix = base_prefix
return SystemEnv(prefix, base_prefix)
@classmethod
def get_base_prefix(cls) -> Path:
......
import os
from pathlib import Path
import pytest
from poetry.__version__ import __version__
from poetry.console.exceptions import PoetrySimpleConsoleException
from poetry.core.packages.package import Package
from poetry.core.semver.version import Version
from poetry.utils._compat import WINDOWS
from poetry.factory import Factory
from poetry.repositories.installed_repository import InstalledRepository
from poetry.repositories.pool import Pool
from poetry.repositories.repository import Repository
from poetry.utils.env import EnvManager
FIXTURES = Path(__file__).parent.joinpath("fixtures")
......@@ -18,75 +21,85 @@ def tester(command_tester_factory):
return command_tester_factory("self update")
def test_self_update_should_install_all_necessary_elements(
tester, http, mocker, environ, tmp_dir
def test_self_update_can_update_from_recommended_installation(
tester, http, mocker, environ, tmp_venv
):
os.environ["POETRY_HOME"] = tmp_dir
mocker.patch.object(EnvManager, "get_system_env", return_value=tmp_venv)
command = tester.command
command._data_dir = tmp_venv.path.parent
new_version = Version.parse(__version__).next_minor().text
old_poetry = Package("poetry", __version__)
old_poetry.add_dependency(Factory.create_dependency("cleo", "^0.8.2"))
new_poetry = Package("poetry", new_version)
new_poetry.add_dependency(Factory.create_dependency("cleo", "^1.0.0"))
installed_repository = Repository()
installed_repository.add_package(old_poetry)
installed_repository.add_package(Package("cleo", "0.8.2"))
repository = Repository()
repository.add_package(new_poetry)
repository.add_package(Package("cleo", "1.0.0"))
pool = Pool()
pool.add_repository(repository)
version = Version.parse(__version__).next_minor().text
mocker.patch(
"poetry.repositories.pypi_repository.PyPiRepository.find_packages",
return_value=[Package("poetry", version)],
)
mocker.patch.object(command, "_check_recommended_installation", return_value=None)
mocker.patch.object(
command, "_get_release_name", return_value="poetry-{}-darwin".format(version)
)
mocker.patch("subprocess.check_output", return_value=b"Python 3.8.2")
http.register_uri(
"GET",
command.BASE_URL + "/{}/poetry-{}-darwin.sha256sum".format(version, version),
body=FIXTURES.joinpath("poetry-1.0.5-darwin.sha256sum").read_bytes(),
)
http.register_uri(
"GET",
command.BASE_URL + "/{}/poetry-{}-darwin.tar.gz".format(version, version),
body=FIXTURES.joinpath("poetry-1.0.5-darwin.tar.gz").read_bytes(),
)
command._pool = pool
mocker.patch.object(InstalledRepository, "load", return_value=installed_repository)
tester.execute()
bin_ = Path(tmp_dir).joinpath("bin")
lib = Path(tmp_dir).joinpath("lib")
assert bin_.exists()
script = bin_.joinpath("poetry")
assert script.exists()
expected_script = """\
# -*- coding: utf-8 -*-
import glob
import sys
import os
lib = os.path.normpath(os.path.join(os.path.realpath(__file__), "../..", "lib"))
vendors = os.path.join(lib, "poetry", "_vendor")
current_vendors = os.path.join(
vendors, "py{}".format(".".join(str(v) for v in sys.version_info[:2]))
)
sys.path.insert(0, lib)
sys.path.insert(0, current_vendors)
if __name__ == "__main__":
from poetry.console import main
main()
expected_output = """\
Updating Poetry to 1.2.0
Updating dependencies
Resolving dependencies...
Package operations: 0 installs, 2 updates, 0 removals
- Updating cleo (0.8.2 -> 1.0.0)
- Updating poetry (1.2.0a0 -> 1.2.0)
Updating the poetry script
Poetry (1.2.0) is installed now. Great!
"""
if not WINDOWS:
expected_script = "#!/usr/bin/env python\n" + expected_script
assert expected_script == script.read_text()
if WINDOWS:
bat = bin_.joinpath("poetry.bat")
expected_bat = '@echo off\r\npython "{}" %*\r\n'.format(
str(script).replace(os.environ.get("USERPROFILE", ""), "%USERPROFILE%")
)
assert bat.exists()
with bat.open(newline="") as f:
assert expected_bat == f.read()
assert lib.exists()
assert lib.joinpath("poetry").exists()
assert tester.io.fetch_output() == expected_output
def test_self_update_does_not_update_non_recommended_installation(
tester, http, mocker, environ, tmp_venv
):
mocker.patch.object(EnvManager, "get_system_env", return_value=tmp_venv)
command = tester.command
new_version = Version.parse(__version__).next_minor().text
old_poetry = Package("poetry", __version__)
old_poetry.add_dependency(Factory.create_dependency("cleo", "^0.8.2"))
new_poetry = Package("poetry", new_version)
new_poetry.add_dependency(Factory.create_dependency("cleo", "^1.0.0"))
installed_repository = Repository()
installed_repository.add_package(old_poetry)
installed_repository.add_package(Package("cleo", "0.8.2"))
repository = Repository()
repository.add_package(new_poetry)
repository.add_package(Package("cleo", "1.0.0"))
pool = Pool()
pool.add_repository(repository)
command._pool = pool
with pytest.raises(PoetrySimpleConsoleException):
tester.execute()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment