Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/changelog/+read-only-app-data-default.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Build the default application data folder only when no folder was given through ``--app-data``, ``VIRTUALENV_APP_DATA``
or the configuration file, so ``--read-only-app-data`` with an explicit folder works when the default folder is missing,
and writable runs stop creating an unused one. Folders from ``VIRTUALENV_APP_DATA`` and the configuration file now get
the same checks as ``--app-data``.
8 changes: 6 additions & 2 deletions src/virtualenv/run/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,11 @@ def load_app_data(
options, _ = parser.parse_known_args(args, namespace=options)

# here we need a write-able application data (e.g. the zipapp might need this for discovery cache)
parser.add_argument(
app_data: Final = parser.add_argument(
"--app-data",
help="a data folder used as cache by the virtualenv",
type=partial(make_app_data, read_only=options.read_only_app_data, env=options.env),
default=make_app_data(None, read_only=options.read_only_app_data, env=options.env),
default=None,
)
parser.add_argument(
"--reset-app-data",
Expand All @@ -182,6 +182,10 @@ def load_app_data(
help="trigger a manual update of the embedded wheels",
)
options, _ = parser.parse_known_args(args, namespace=options)
if options.app_data is None:
# build the default only when no folder was given, since building it creates the folder or, when read-only,
# requires it to exist; it is also stored on the action so that --help still shows it
options.app_data = app_data.default = make_app_data(None, read_only=options.read_only_app_data, env=options.env)
if options.reset_app_data:
options.app_data.reset()
return options
Expand Down
95 changes: 94 additions & 1 deletion tests/unit/test_run.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
from __future__ import annotations

import logging
import re
import shutil
import sys
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Final

import pytest

from virtualenv import __version__
from virtualenv.app_data import AppDataDiskFolder, ReadOnlyAppData
from virtualenv.config.cli.parser import VirtualEnvOptions
from virtualenv.run import cli_run, session_via_cli

if TYPE_CHECKING:
from pathlib import Path
from collections.abc import Callable

from pytest_mock import MockerFixture

Expand All @@ -30,6 +35,94 @@ def test_missing_option_value_does_not_create_app_data(tmp_path: Path, option: s
assert not (tmp_path / "cache").exists()


def test_read_only_app_data_ignores_missing_default(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, give_app_data: Callable[[Path], list[str]]
) -> None:
cache: Final = tmp_path / "cache"
session_via_cli(["--app-data", str(cache), str(tmp_path / "warm")]) # discovery cannot fill a read-only cache
monkeypatch.setenv("VIRTUALENV_OVERRIDE_APP_DATA", str(tmp_path / "default"))
options: Final = VirtualEnvOptions()
session_via_cli(["--read-only-app-data", *give_app_data(cache), str(tmp_path / "env")], options)
assert (type(options.app_data), str(options.app_data)) == (ReadOnlyAppData, str(cache))


@pytest.mark.parametrize(
"prepare_default",
[
pytest.param(lambda _: None, id="missing"),
# a file in the way makes the default fall back to temporary app-data
pytest.param(Path.touch, id="blocked"),
],
)
def test_given_app_data_leaves_default_alone(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
give_app_data: Callable[[Path], list[str]],
prepare_default: Callable[[Path], None],
) -> None:
default_folder: Final = tmp_path / "default"
prepare_default(default_folder)
monkeypatch.setenv("VIRTUALENV_OVERRIDE_APP_DATA", str(default_folder / "cache"))
cache: Final = tmp_path / "cache"
options: Final = VirtualEnvOptions()
session_via_cli([*give_app_data(cache), str(tmp_path / "env")], options)
assert (type(options.app_data), str(options.app_data), (default_folder / "cache").exists()) == (
AppDataDiskFolder,
str(cache),
False,
)


def test_read_only_app_data_rejects_missing_given_folder(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, give_app_data: Callable[[Path], list[str]]
) -> None:
(tmp_path / "default").mkdir()
monkeypatch.setenv("VIRTUALENV_OVERRIDE_APP_DATA", str(tmp_path / "default"))
cache: Final = tmp_path / "cache"
args: Final = ["--read-only-app-data", *give_app_data(cache), str(tmp_path / "env")]
with pytest.raises(RuntimeError, match=rf"^read-only app data directory {re.escape(str(cache))} does not exist$"):
session_via_cli(args)


def test_help_shows_default_app_data(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setenv("COLUMNS", "1000")
monkeypatch.setenv("VIRTUALENV_OVERRIDE_APP_DATA", str(tmp_path))
with pytest.raises(SystemExit) as context:
cli_run(args=["--help"])
assert (
context.value.code,
f"a data folder used as cache by the virtualenv (default: {tmp_path})" in capsys.readouterr().out,
) == (0, True)


def _on_command_line(_: pytest.MonkeyPatch, folder: Path) -> list[str]:
return ["--app-data", str(folder)]


def _in_environment(monkeypatch: pytest.MonkeyPatch, folder: Path) -> list[str]:
monkeypatch.setenv("VIRTUALENV_APP_DATA", str(folder))
return []


def _in_config_file(monkeypatch: pytest.MonkeyPatch, folder: Path) -> list[str]:
(ini := folder.parent / "virtualenv.ini").write_text(f"[virtualenv]\napp_data = {folder}\n", encoding="utf-8")
monkeypatch.setenv("VIRTUALENV_CONFIG_FILE", str(ini))
return []


@pytest.fixture(
params=[
pytest.param(_on_command_line, id="cli"),
pytest.param(_in_environment, id="env-var"),
pytest.param(_in_config_file, id="ini"),
]
)
def give_app_data(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Callable[[Path], list[str]]:
return partial(request.param, monkeypatch)


def test_help(capsys) -> None:
with pytest.raises(SystemExit) as context:
cli_run(args=["-h", "-vvv"])
Expand Down