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: ...@@ -72,7 +72,7 @@ You can specify a package in the following forms:
plugins = self.argument("plugins") plugins = self.argument("plugins")
# Plugins should be installed in the system env to be globally available # 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( env_dir = Path(
os.getenv("POETRY_HOME") if os.getenv("POETRY_HOME") else system_env.path os.getenv("POETRY_HOME") if os.getenv("POETRY_HOME") else system_env.path
......
...@@ -43,7 +43,7 @@ class PluginRemoveCommand(Command): ...@@ -43,7 +43,7 @@ class PluginRemoveCommand(Command):
plugins = self.argument("plugins") plugins = self.argument("plugins")
system_env = EnvManager.get_system_env() system_env = EnvManager.get_system_env(naive=True)
env_dir = Path( env_dir = Path(
os.getenv("POETRY_HOME") if os.getenv("POETRY_HOME") else system_env.path os.getenv("POETRY_HOME") if os.getenv("POETRY_HOME") else system_env.path
) )
......
...@@ -38,7 +38,7 @@ class PluginShowCommand(Command): ...@@ -38,7 +38,7 @@ class PluginShowCommand(Command):
+ PluginManager("plugin").get_plugin_entry_points() + PluginManager("plugin").get_plugin_entry_points()
) )
system_env = EnvManager.get_system_env() system_env = EnvManager.get_system_env(naive=True)
installed_repository = InstalledRepository.load( installed_repository = InstalledRepository.load(
system_env, with_dependencies=True system_env, with_dependencies=True
) )
......
from __future__ import unicode_literals
import hashlib
import os import os
import re
import shutil import shutil
import stat import site
import subprocess
import sys
import tarfile
from functools import cmp_to_key from functools import cmp_to_key
from gzip import GzipFile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING 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 argument
from cleo.helpers import option from cleo.helpers import option
...@@ -26,27 +15,7 @@ from ..command import Command ...@@ -26,27 +15,7 @@ from ..command import Command
if TYPE_CHECKING: if TYPE_CHECKING:
from poetry.core.packages.package import Package from poetry.core.packages.package import Package
from poetry.core.semver.version import Version from poetry.core.semver.version import Version
from poetry.repositories.pool import Pool
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'
class SelfUpdateCommand(Command): class SelfUpdateCommand(Command):
...@@ -55,48 +24,81 @@ class SelfUpdateCommand(Command): ...@@ -55,48 +24,81 @@ class SelfUpdateCommand(Command):
description = "Updates Poetry to the latest version." description = "Updates Poetry to the latest version."
arguments = [argument("version", "The version to update to.", optional=True)] arguments = [argument("version", "The version to update to.", optional=True)]
options = [option("preview", None, "Install prereleases.")] 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).",
),
]
REPOSITORY_URL = "https://github.com/python-poetry/poetry" _data_dir = None
BASE_URL = REPOSITORY_URL + "/releases/download" _bin_dir = None
_pool = None
@property @property
def home(self) -> Path: def data_dir(self) -> Path:
from pathlib import 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 self._data_dir = data_dir()
def bin(self) -> Path:
return self.home / "bin" return self._data_dir
@property @property
def lib(self) -> Path: def bin_dir(self) -> Path:
return self.home / "lib" 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 @property
def lib_backup(self) -> Path: def pool(self) -> "Pool":
return self.home / "lib-backup" 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.__version__ import __version__
from poetry.core.packages.dependency import Dependency from poetry.core.packages.dependency import Dependency
from poetry.core.semver.version import Version from poetry.core.semver.version import Version
from poetry.repositories.pypi_repository import PyPiRepository
self._check_recommended_installation()
version = self.argument("version") version = self.argument("version")
if not version: if not version:
version = ">=" + __version__ version = ">=" + __version__
repo = PyPiRepository(fallback=False) repo = self.pool.repositories[0]
packages = repo.find_packages( packages = repo.find_packages(
Dependency("poetry", version, allows_prereleases=self.option("preview")) Dependency("poetry", version, allows_prereleases=self.option("preview"))
) )
if not packages: if not packages:
self.line("No release found for the specified version") self.line("No release found for the specified version")
return return 1
packages.sort( packages.sort(
key=cmp_to_key( key=cmp_to_key(
...@@ -122,205 +124,101 @@ class SelfUpdateCommand(Command): ...@@ -122,205 +124,101 @@ class SelfUpdateCommand(Command):
if release is None: if release is None:
self.line("No new release found") self.line("No new release found")
return return 1
if release.version == Version.parse(__version__): if release.version == Version.parse(__version__):
self.line("You are using the latest version") self.line("You are using the latest version")
return return 0
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))
# Backup the current installation
if self.lib.exists():
shutil.copytree(str(self.lib), str(self.lib_backup))
shutil.rmtree(str(self.lib))
try: self.line("Updating <c1>Poetry</c1> to <c2>{}</c2>".format(release.version))
self._update(version) self.line("")
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.make_bin() self.update(release)
self.line("") self.line("")
self.line("")
self.line( self.line(
"<info>Poetry</info> (<comment>{}</comment>) is installed now. Great!".format( "<c1>Poetry</c1> (<c2>{}</c2>) is installed now. Great!".format(
version release.version
) )
) )
def _update(self, version: "Version") -> None: return 0
from poetry.utils.helpers import temporary_directory
release_name = self._get_release_name(version)
checksum = "{}.sha256sum".format(release_name)
base_url = self.BASE_URL def update(self, release: "Package") -> None:
from poetry.utils.env import EnvManager
try:
r = urlopen(base_url + "/{}/{}".format(version, checksum))
except HTTPError as e:
if e.code == 404:
raise RuntimeError("Could not find {} file".format(checksum))
raise
checksum = r.read().decode().strip()
# 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() version = release.version
# Checking hashes env = EnvManager.get_system_env(naive=True)
if checksum != sha.hexdigest():
raise RuntimeError(
"Hashes for {} do not match: {} != {}".format(
name, checksum, sha.hexdigest()
)
)
gz = GzipFile(tar, mode="rb") # We can't use is_relative_to() since it's only available in Python 3.9+
try: try:
with tarfile.TarFile(tar, fileobj=gz, format=tarfile.PAX_FORMAT) as f: env.path.relative_to(self.data_dir)
f.extractall(str(self.lib)) except ValueError:
finally: # Poetry was not installed using the recommended installer
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 from poetry.console.exceptions import PoetrySimpleConsoleException
current = Path(__file__)
try:
current.relative_to(self.home)
except ValueError:
raise PoetrySimpleConsoleException( raise PoetrySimpleConsoleException(
"Poetry was not installed with the recommended installer, " "Poetry was not installed with the recommended installer, "
"so it cannot be updated automatically." "so it cannot be updated automatically."
) )
def _get_release_name(self, version: "Version") -> str: self._update(version)
platform = sys.platform self._make_bin()
if platform == "linux2":
platform = "linux"
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 from poetry.utils._compat import WINDOWS
self.bin.mkdir(0o755, parents=True, exist_ok=True) self.line("")
self.line("Updating the <c1>poetry</c1> script")
python_executable = self._which_python() self.bin_dir.mkdir(parents=True, exist_ok=True)
script = "poetry"
target_script = "venv/bin/poetry"
if WINDOWS: if WINDOWS:
with self.bin.joinpath("poetry.bat").open("w", newline="") as f: script = "poetry.exe"
f.write( target_script = "venv/Scripts/poetry.exe"
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: if self.bin_dir.joinpath(script).exists():
""" self.bin_dir.joinpath(script).unlink()
Decides which python executable we'll embed in the launcher script.
"""
from poetry.utils._compat import WINDOWS
allowed_executables = ["python", "python3"]
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: try:
raw_version = subprocess.check_output( self.bin_dir.joinpath(script).symlink_to(
executable + " --version", stderr=subprocess.STDOUT, shell=True self.data_dir.joinpath(target_script)
).decode("utf-8") )
except subprocess.CalledProcessError: except OSError:
continue # This can happen if the user
# does not have the correct permission on Windows
match = version_matcher.match(raw_version.strip()) shutil.copy(
if match and tuple(map(int, match.groups())) >= (3, 0): self.data_dir.joinpath(target_script), self.bin_dir.joinpath(script)
# 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
if fallback is None:
# Avoid breaking existing scripts
fallback = "python"
return fallback
import os
from pathlib import Path from pathlib import Path
from .utils.appdirs import user_cache_dir from .utils.appdirs import user_cache_dir
...@@ -10,3 +12,10 @@ DATA_DIR = user_data_dir("pypoetry") ...@@ -10,3 +12,10 @@ DATA_DIR = user_data_dir("pypoetry")
CONFIG_DIR = user_config_dir("pypoetry") CONFIG_DIR = user_config_dir("pypoetry")
REPOSITORY_CACHE_DIR = Path(CACHE_DIR) / "cache" / "repositories" 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: ...@@ -595,3 +595,8 @@ class Locker:
data["develop"] = package.develop data["develop"] = package.develop
return data return data
class NullLocker(Locker):
def set_lock_data(self, root: Package, packages: List[Package]) -> bool:
pass
...@@ -996,8 +996,31 @@ class EnvManager: ...@@ -996,8 +996,31 @@ class EnvManager:
shutil.rmtree(str(file_path)) shutil.rmtree(str(file_path))
@classmethod @classmethod
def get_system_env(cls) -> "SystemEnv": def get_system_env(cls, naive: bool = False) -> "SystemEnv":
return SystemEnv(Path(sys.prefix), cls.get_base_prefix()) """
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 @classmethod
def get_base_prefix(cls) -> Path: def get_base_prefix(cls) -> Path:
......
import os
from pathlib import Path from pathlib import Path
import pytest import pytest
from poetry.__version__ import __version__ from poetry.__version__ import __version__
from poetry.console.exceptions import PoetrySimpleConsoleException
from poetry.core.packages.package import Package from poetry.core.packages.package import Package
from poetry.core.semver.version import Version 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") FIXTURES = Path(__file__).parent.joinpath("fixtures")
...@@ -18,75 +21,85 @@ def tester(command_tester_factory): ...@@ -18,75 +21,85 @@ def tester(command_tester_factory):
return command_tester_factory("self update") return command_tester_factory("self update")
def test_self_update_should_install_all_necessary_elements( def test_self_update_can_update_from_recommended_installation(
tester, http, mocker, environ, tmp_dir 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 = 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 command._pool = pool
mocker.patch(
"poetry.repositories.pypi_repository.PyPiRepository.find_packages", mocker.patch.object(InstalledRepository, "load", return_value=installed_repository)
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(),
)
tester.execute() tester.execute()
bin_ = Path(tmp_dir).joinpath("bin") expected_output = """\
lib = Path(tmp_dir).joinpath("lib") Updating Poetry to 1.2.0
assert bin_.exists()
Updating dependencies
script = bin_.joinpath("poetry") Resolving dependencies...
assert script.exists()
Package operations: 0 installs, 2 updates, 0 removals
expected_script = """\
# -*- coding: utf-8 -*- - Updating cleo (0.8.2 -> 1.0.0)
import glob - Updating poetry (1.2.0a0 -> 1.2.0)
import sys
import os Updating the poetry script
lib = os.path.normpath(os.path.join(os.path.realpath(__file__), "../..", "lib")) Poetry (1.2.0) is installed now. Great!
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()
""" """
if not WINDOWS:
expected_script = "#!/usr/bin/env python\n" + expected_script assert tester.io.fetch_output() == expected_output
assert expected_script == script.read_text()
def test_self_update_does_not_update_non_recommended_installation(
if WINDOWS: tester, http, mocker, environ, tmp_venv
bat = bin_.joinpath("poetry.bat") ):
expected_bat = '@echo off\r\npython "{}" %*\r\n'.format( mocker.patch.object(EnvManager, "get_system_env", return_value=tmp_venv)
str(script).replace(os.environ.get("USERPROFILE", ""), "%USERPROFILE%")
) command = tester.command
assert bat.exists()
with bat.open(newline="") as f: new_version = Version.parse(__version__).next_minor().text
assert expected_bat == f.read()
old_poetry = Package("poetry", __version__)
assert lib.exists() old_poetry.add_dependency(Factory.create_dependency("cleo", "^0.8.2"))
assert lib.joinpath("poetry").exists()
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