Skip to content

Commit

Permalink
Merge branch 'master' into jfob/update-basic-usage
Browse files Browse the repository at this point in the history
  • Loading branch information
radoering authored Jan 5, 2024
2 parents 4f404f6 + f09174e commit 3fcaf58
Show file tree
Hide file tree
Showing 8 changed files with 182 additions and 71 deletions.
1 change: 1 addition & 0 deletions docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Poetry should always be installed in a dedicated virtual environment to isolate
It should in no case be installed in the environment of the project that is to be managed by Poetry.
This ensures that Poetry's own dependencies will not be accidentally upgraded or uninstalled.
(Each of the following installation methods ensures that Poetry is installed into an isolated environment.)
In addition, the isolated virtual environment in which poetry is installed should not be activated for running poetry commands.
{{% /warning %}}

{{% note %}}
Expand Down
8 changes: 8 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,14 @@ poetry config [options] [setting-key] [setting-value1] ... [setting-valueN]
`setting-key` is a configuration option name and `setting-value1` is a configuration value.
See [Configuration]({{< relref "configuration" >}}) for all available settings.

{{% warning %}}
Use `--` to terminate option parsing if your values may start with a hyphen (`-`), e.g.
```bash
poetry config http-basic.custom-repo gitlab-ci-token -- ${GITLAB_JOB_TOKEN}
```
Without `--` this command will fail if `${GITLAB_JOB_TOKEN}` starts with a hyphen.
{{% /warning%}}

### Options

* `--unset`: Remove the configuration element named by `setting-key`.
Expand Down
5 changes: 5 additions & 0 deletions src/poetry/console/commands/env/remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,10 @@ def handle(self) -> int:
for venv in manager.list():
manager.remove_venv(venv.path)
self.line(f"Deleted virtualenv: <comment>{venv.path}</comment>")
# Since we remove all the virtualenvs, we can also remove the entry
# in the envs file. (Strictly speaking, we should do this explicitly,
# in case it points to a virtualenv that had been removed manually before.)
if manager.envs_file.exists():
manager.envs_file.remove_section(manager.base_env_name)

return 0
2 changes: 1 addition & 1 deletion src/poetry/console/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def handle(self) -> int:
self.line(self.poetry.package.pretty_version)
else:
self.line(
f"<comment>{self.poetry.package.name}</>"
f"<comment>{self.poetry.package.pretty_name}</>"
f" <info>{self.poetry.package.pretty_version}</>"
)

Expand Down
147 changes: 77 additions & 70 deletions src/poetry/utils/env/env_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import subprocess
import sys

from functools import cached_property
from pathlib import Path
from subprocess import CalledProcessError
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -45,6 +46,44 @@
from poetry.utils.env.base_env import Env


class EnvsFile(TOMLFile):
"""
This file contains one section per project with the project's base env name
as section name. Each section contains the minor and patch version of the
python executable used to create the currently active virtualenv.
Example:
[poetry-QRErDmmj]
minor = "3.9"
patch = "3.9.13"
[poetry-core-m5r7DkRA]
minor = "3.11"
patch = "3.11.6"
"""

def remove_section(self, name: str, minor: str | None = None) -> str | None:
"""
Remove a section from the envs file.
If "minor" is given, the section is only removed if its minor value
matches "minor".
Returns the "minor" value of the removed section.
"""
envs = self.read()
current_env = envs.get(name)
if current_env is not None and (not minor or current_env["minor"] == minor):
del envs[name]
self.write(envs)
minor = current_env["minor"]
assert isinstance(minor, str)
return minor

return None


class EnvManager:
"""
Environments manager
Expand Down Expand Up @@ -121,11 +160,19 @@ def in_project_venv(self) -> Path:
venv: Path = self._poetry.file.path.parent / ".venv"
return venv

@cached_property
def envs_file(self) -> EnvsFile:
return EnvsFile(self._poetry.config.virtualenvs_path / self.ENVS_FILE)

@cached_property
def base_env_name(self) -> str:
return self.generate_env_name(
self._poetry.package.name,
str(self._poetry.file.path.parent),
)

def activate(self, python: str) -> Env:
venv_path = self._poetry.config.virtualenvs_path
cwd = self._poetry.file.path.parent

envs_file = TOMLFile(venv_path / self.ENVS_FILE)

try:
python_version = Version.parse(python)
Expand Down Expand Up @@ -170,10 +217,9 @@ def activate(self, python: str) -> Env:
return self.get(reload=True)

envs = tomlkit.document()
base_env_name = self.generate_env_name(self._poetry.package.name, str(cwd))
if envs_file.exists():
envs = envs_file.read()
current_env = envs.get(base_env_name)
if self.envs_file.exists():
envs = self.envs_file.read()
current_env = envs.get(self.base_env_name)
if current_env is not None:
current_minor = current_env["minor"]
current_patch = current_env["patch"]
Expand All @@ -182,7 +228,7 @@ def activate(self, python: str) -> Env:
# We need to recreate
create = True

name = f"{base_env_name}-py{minor}"
name = f"{self.base_env_name}-py{minor}"
venv = venv_path / name

# Create if needed
Expand All @@ -202,29 +248,21 @@ def activate(self, python: str) -> Env:
self.create_venv(executable=python_path, force=create)

# Activate
envs[base_env_name] = {"minor": minor, "patch": patch}
envs_file.write(envs)
envs[self.base_env_name] = {"minor": minor, "patch": patch}
self.envs_file.write(envs)

return self.get(reload=True)

def deactivate(self) -> None:
venv_path = self._poetry.config.virtualenvs_path
name = self.generate_env_name(
self._poetry.package.name, str(self._poetry.file.path.parent)
)

envs_file = TOMLFile(venv_path / self.ENVS_FILE)
if envs_file.exists():
envs = envs_file.read()
env = envs.get(name)
if env is not None:
venv = venv_path / f"{name}-py{env['minor']}"
self._io.write_error_line(
f"Deactivating virtualenv: <comment>{venv}</comment>"
)
del envs[name]

envs_file.write(envs)
if self.envs_file.exists() and (
minor := self.envs_file.remove_section(self.base_env_name)
):
venv = venv_path / f"{self.base_env_name}-py{minor}"
self._io.write_error_line(
f"Deactivating virtualenv: <comment>{venv}</comment>"
)

def get(self, reload: bool = False) -> Env:
if self._env is not None and not reload:
Expand All @@ -237,15 +275,10 @@ def get(self, reload: bool = False) -> Env:
precision=2, prefer_active_python=prefer_active_python, io=self._io
).to_string()

venv_path = self._poetry.config.virtualenvs_path

cwd = self._poetry.file.path.parent
envs_file = TOMLFile(venv_path / self.ENVS_FILE)
env = None
base_env_name = self.generate_env_name(self._poetry.package.name, str(cwd))
if envs_file.exists():
envs = envs_file.read()
env = envs.get(base_env_name)
if self.envs_file.exists():
envs = self.envs_file.read()
env = envs.get(self.base_env_name)
if env:
python_minor = env["minor"]

Expand All @@ -272,7 +305,7 @@ def get(self, reload: bool = False) -> Env:

venv_path = self._poetry.config.virtualenvs_path

name = f"{base_env_name}-py{python_minor.strip()}"
name = f"{self.base_env_name}-py{python_minor.strip()}"

venv = venv_path / name

Expand Down Expand Up @@ -313,12 +346,6 @@ def check_env_is_for_current_project(env: str, base_env_name: str) -> bool:
return env.startswith(base_env_name)

def remove(self, python: str) -> Env:
venv_path = self._poetry.config.virtualenvs_path

cwd = self._poetry.file.path.parent
envs_file = TOMLFile(venv_path / self.ENVS_FILE)
base_env_name = self.generate_env_name(self._poetry.package.name, str(cwd))

python_path = Path(python)
if python_path.is_file():
# Validate env name if provided env is a full path to python
Expand All @@ -327,34 +354,21 @@ def remove(self, python: str) -> Env:
[python, "-c", GET_ENV_PATH_ONELINER], text=True
).strip("\n")
env_name = Path(env_dir).name
if not self.check_env_is_for_current_project(env_name, base_env_name):
if not self.check_env_is_for_current_project(
env_name, self.base_env_name
):
raise IncorrectEnvError(env_name)
except CalledProcessError as e:
raise EnvCommandError(e)

if self.check_env_is_for_current_project(python, base_env_name):
if self.check_env_is_for_current_project(python, self.base_env_name):
venvs = self.list()
for venv in venvs:
if venv.path.name == python:
# Exact virtualenv name
if not envs_file.exists():
self.remove_venv(venv.path)

return venv

venv_minor = ".".join(str(v) for v in venv.version_info[:2])
base_env_name = self.generate_env_name(cwd.name, str(cwd))
envs = envs_file.read()

current_env = envs.get(base_env_name)
if not current_env:
self.remove_venv(venv.path)

return venv

if current_env["minor"] == venv_minor:
del envs[base_env_name]
envs_file.write(envs)
if self.envs_file.exists():
venv_minor = ".".join(str(v) for v in venv.version_info[:2])
self.envs_file.remove_section(self.base_env_name, venv_minor)

self.remove_venv(venv.path)

Expand Down Expand Up @@ -389,21 +403,14 @@ def remove(self, python: str) -> Env:
python_version = Version.parse(python_version_string.strip())
minor = f"{python_version.major}.{python_version.minor}"

name = f"{base_env_name}-py{minor}"
name = f"{self.base_env_name}-py{minor}"
venv_path = venv_path / name

if not venv_path.exists():
raise ValueError(f'<warning>Environment "{name}" does not exist.</warning>')

if envs_file.exists():
envs = envs_file.read()
current_env = envs.get(base_env_name)
if current_env is not None:
current_minor = current_env["minor"]

if current_minor == minor:
del envs[base_env_name]
envs_file.write(envs)
if self.envs_file.exists():
self.envs_file.remove_section(self.base_env_name, minor)

self.remove_venv(venv_path)

Expand Down
31 changes: 31 additions & 0 deletions tests/console/commands/env/test_remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,50 @@ def test_remove_by_name(
assert tester.io.fetch_output() == expected


@pytest.mark.parametrize(
"envs_file", [None, "empty", "self", "other", "self_and_other"]
)
def test_remove_all(
tester: CommandTester,
venvs_in_cache_dirs: list[str],
venv_name: str,
venv_cache: Path,
envs_file: str | None,
) -> None:
envs_file_path = venv_cache / "envs.toml"
if envs_file == "empty":
envs_file_path.touch()
elif envs_file == "self":
envs_file_path.write_text(f'[{venv_name}]\nminor = "3.9"\npatch = "3.9.1"\n')
elif envs_file == "other":
envs_file_path.write_text('[other-abcdefgh]\nminor = "3.9"\npatch = "3.9.1"\n')
elif envs_file == "self_and_other":
envs_file_path.write_text(
f'[{venv_name}]\nminor = "3.9"\npatch = "3.9.1"\n'
'[other-abcdefgh]\nminor = "3.9"\npatch = "3.9.1"\n'
)
else:
# no envs file -> nothing to prepare
assert envs_file is None

expected = {""}
tester.execute("--all")
for name in venvs_in_cache_dirs:
assert not (venv_cache / name).exists()
expected.add(f"Deleted virtualenv: {venv_cache / name}")
assert set(tester.io.fetch_output().split("\n")) == expected

if envs_file is not None:
assert envs_file_path.exists()
envs_file_content = envs_file_path.read_text()
assert venv_name not in envs_file_content
if "other" in envs_file:
assert "other-abcdefgh" in envs_file_content
else:
assert envs_file_content == ""
else:
assert not envs_file_path.exists()


def test_remove_all_and_version(
tester: CommandTester,
Expand Down
23 changes: 23 additions & 0 deletions tests/console/commands/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
if TYPE_CHECKING:
from cleo.testers.command_tester import CommandTester

from poetry.poetry import Poetry
from tests.types import CommandTesterFactory
from tests.types import FixtureDirGetter
from tests.types import ProjectFactory


@pytest.fixture()
Expand All @@ -23,6 +26,18 @@ def tester(command_tester_factory: CommandTesterFactory) -> CommandTester:
return command_tester_factory("version")


@pytest.fixture
def poetry_with_underscore(
project_factory: ProjectFactory, fixture_dir: FixtureDirGetter
) -> Poetry:
source = fixture_dir("simple_project")
pyproject_content = (source / "pyproject.toml").read_text(encoding="utf-8")
pyproject_content = pyproject_content.replace("simple-project", "simple_project")
return project_factory(
"project_with_underscore", pyproject_content=pyproject_content
)


@pytest.mark.parametrize(
"version, rule, expected",
[
Expand Down Expand Up @@ -79,6 +94,14 @@ def test_version_show(tester: CommandTester) -> None:
assert tester.io.fetch_output() == "simple-project 1.2.3\n"


def test_version_show_with_underscore(
command_tester_factory: CommandTesterFactory, poetry_with_underscore: Poetry
) -> None:
tester = command_tester_factory("version", poetry=poetry_with_underscore)
tester.execute()
assert tester.io.fetch_output() == "simple_project 1.2.3\n"


def test_short_version_show(tester: CommandTester) -> None:
tester.execute("--short")
assert tester.io.fetch_output() == "1.2.3\n"
Expand Down
Loading

0 comments on commit 3fcaf58

Please sign in to comment.