# Copyright 2016 Alethea Katherine Flowers
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import argparse
import logging
import operator
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest import mock

import pytest

import nox.command
import nox.manifest
import nox.popen
import nox.registry
import nox.sessions
import nox.virtualenv
from nox import _options
from nox.logger import logger

HAS_CONDA = shutil.which("conda") is not None
has_conda = pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.")


def run_with_defaults(**kwargs):
    return {
        "env": None,
        "silent": False,
        "paths": None,
        "success_codes": None,
        "log": True,
        "external": False,
        "stdout": None,
        "stderr": subprocess.STDOUT,
        "interrupt_timeout": nox.popen.DEFAULT_INTERRUPT_TIMEOUT,
        "terminate_timeout": nox.popen.DEFAULT_TERMINATE_TIMEOUT,
        **kwargs,
    }


def _run_with_defaults(**kwargs):
    return {
        "env": None,
        "include_outer_env": True,
        "silent": False,
        "success_codes": None,
        "log": True,
        "external": False,
        "stdout": None,
        "stderr": subprocess.STDOUT,
        "interrupt_timeout": nox.popen.DEFAULT_INTERRUPT_TIMEOUT,
        "terminate_timeout": nox.popen.DEFAULT_TERMINATE_TIMEOUT,
        **kwargs,
    }


def test__normalize_path():
    envdir = "envdir"
    normalize = nox.sessions._normalize_path
    assert normalize(envdir, "hello") == os.path.join("envdir", "hello")
    assert normalize(envdir, b"hello") == os.path.join("envdir", "hello")
    assert normalize(envdir, "hello(world)") == os.path.join("envdir", "hello-world")
    assert normalize(envdir, "hello(world, meep)") == os.path.join(
        "envdir", "hello-world-meep"
    )
    assert normalize(envdir, 'tests(interpreter="python2.7", django="1.10")') == (
        os.path.join("envdir", "tests-interpreter-python2-7-django-1-10")
    )


def test__normalize_path_hash():
    envdir = "d" * (100 - len("bin/pythonX.Y") - 10)
    norm_path = nox.sessions._normalize_path(envdir, "a-really-long-virtualenv-path")
    assert "a-really-long-virtualenv-path" not in norm_path
    assert len(norm_path) < 100


def test__normalize_path_give_up():
    envdir = "d" * 100
    norm_path = nox.sessions._normalize_path(envdir, "any-path")
    assert "any-path" in norm_path


class TestSession:
    def make_session_and_runner(self):
        func = mock.Mock(spec=["python"], python="3.7")
        runner = nox.sessions.SessionRunner(
            name="test",
            signatures=["test"],
            func=func,
            global_config=_options.options.namespace(
                posargs=[],
                error_on_external_run=False,
                install_only=False,
                invoked_from=os.getcwd(),
            ),
            manifest=mock.create_autospec(nox.manifest.Manifest),
        )
        runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
        runner.venv.env = {}
        runner.venv.bin_paths = ["/no/bin/for/you"]
        runner.venv.venv_backend = "venv"
        return nox.sessions.Session(runner=runner), runner

    def test_create_tmp(self):
        session, runner = self.make_session_and_runner()
        with tempfile.TemporaryDirectory() as root:
            runner.global_config.envdir = root
            tmpdir = session.create_tmp()
            assert session.env["TMPDIR"] == os.path.abspath(tmpdir)
            assert tmpdir.startswith(root)

    def test_create_tmp_twice(self):
        session, runner = self.make_session_and_runner()
        with tempfile.TemporaryDirectory() as root:
            runner.global_config.envdir = root
            runner.venv.bin = bin
            session.create_tmp()
            tmpdir = session.create_tmp()
            assert session.env["TMPDIR"] == os.path.abspath(tmpdir)
            assert tmpdir.startswith(root)

    def test_properties(self):
        session, runner = self.make_session_and_runner()
        with tempfile.TemporaryDirectory() as root:
            runner.global_config.envdir = root

            assert session.name is runner.friendly_name
            assert session.env is runner.venv.env
            assert session.posargs == runner.global_config.posargs
            assert session.virtualenv is runner.venv
            assert session.bin_paths is runner.venv.bin_paths
            assert session.bin is runner.venv.bin_paths[0]
            assert session.python is runner.func.python
            assert session.invoked_from is runner.global_config.invoked_from
            assert session.cache_dir == Path(runner.global_config.envdir).joinpath(
                ".cache"
            )

    def test_no_bin_paths(self):
        session, runner = self.make_session_and_runner()

        runner.venv.bin_paths = None
        with pytest.raises(
            ValueError, match=r"^The environment does not have a bin directory\.$"
        ):
            session.bin  # noqa: B018
        assert session.bin_paths is None

    def test_virtualenv_as_none(self):
        session, runner = self.make_session_and_runner()

        runner.venv = None

        with pytest.raises(ValueError, match="virtualenv"):
            _ = session.virtualenv

        assert session.venv_backend == "none"

    def test_interactive(self):
        session, runner = self.make_session_and_runner()

        with mock.patch("nox.sessions.sys.stdin.isatty") as m_isatty:
            m_isatty.return_value = True

            assert session.interactive is True

            m_isatty.return_value = False

            assert session.interactive is False

    def test_explicit_non_interactive(self):
        session, runner = self.make_session_and_runner()

        with mock.patch("nox.sessions.sys.stdin.isatty") as m_isatty:
            m_isatty.return_value = True
            runner.global_config.non_interactive = True

            assert session.interactive is False

    def test_chdir(self, tmpdir):
        cdto = str(tmpdir.join("cdbby").ensure(dir=True))
        current_cwd = os.getcwd()

        session, _ = self.make_session_and_runner()

        session.chdir(cdto)

        assert os.getcwd() == cdto
        os.chdir(current_cwd)

    def test_chdir_ctx(self, tmpdir):
        cdto = str(tmpdir.join("cdbby").ensure(dir=True))
        current_cwd = os.getcwd()

        session, _ = self.make_session_and_runner()

        with session.chdir(cdto):
            assert os.getcwd() == cdto

        assert os.getcwd() == current_cwd

        os.chdir(current_cwd)

    def test_invoked_from(self, tmpdir):
        cdto = str(tmpdir.join("cdbby").ensure(dir=True))
        current_cwd = os.getcwd()

        session, _ = self.make_session_and_runner()

        session.chdir(cdto)

        assert session.invoked_from == current_cwd
        os.chdir(current_cwd)

    def test_chdir_pathlib(self, tmpdir):
        cdto = str(tmpdir.join("cdbby").ensure(dir=True))
        current_cwd = os.getcwd()

        session, _ = self.make_session_and_runner()

        session.chdir(Path(cdto))

        assert os.getcwd() == cdto
        os.chdir(current_cwd)

    def test_run_bad_args(self):
        session, _ = self.make_session_and_runner()

        with pytest.raises(ValueError, match="arg"):
            session.run()

    def test_run_with_func(self):
        session, _ = self.make_session_and_runner()

        assert session.run(operator.add, 1, 2) == 3

    def test_run_with_func_error(self):
        session, _ = self.make_session_and_runner()

        def raise_value_error():
            raise ValueError("meep")

        with pytest.raises(nox.command.CommandFailed):
            assert session.run(raise_value_error)

    def test_run_install_only(self, caplog):
        caplog.set_level(logging.INFO)
        session, runner = self.make_session_and_runner()
        runner.global_config.install_only = True

        with mock.patch.object(nox.command, "run") as run:
            assert session.run("spam", "eggs") is None

        run.assert_not_called()

        assert "install-only" in caplog.text

    def test_run_install_only_should_install(self):
        session, runner = self.make_session_and_runner()
        runner.global_config.install_only = True

        with mock.patch.object(nox.command, "run") as run:
            session.install("spam")
            session.run("spam", "eggs")

        run.assert_called_once_with(
            ("python", "-m", "pip", "install", "spam"),
            **run_with_defaults(paths=mock.ANY, silent=True, env={}, external="error"),
        )

    def test_run_success(self):
        session, _ = self.make_session_and_runner()
        result = session.run(sys.executable, "-c", "print(123)")
        assert result

    def test_run_error(self):
        session, _ = self.make_session_and_runner()

        with pytest.raises(nox.command.CommandFailed):
            session.run(sys.executable, "-c", "import sys; sys.exit(1)")

    def test_run_overly_env(self):
        session, runner = self.make_session_and_runner()
        runner.venv.env["A"] = "1"
        runner.venv.env["B"] = "2"
        runner.venv.env["C"] = "4"
        result = session.run(
            sys.executable,
            "-c",
            'import os; print(os.environ["A"], os.environ["B"], os.environ.get("C", "5"))',
            env={"B": "3", "C": None},
            silent=True,
        )
        assert result.strip() == "1 3 5"

    def test_by_default_all_invocation_env_vars_are_passed(self):
        session, runner = self.make_session_and_runner()
        runner.venv.env["I_SHOULD_BE_INCLUDED"] = "happy"
        runner.venv.env["I_SHOULD_BE_INCLUDED_TOO"] = "happier"
        runner.venv.env["EVERYONE_SHOULD_BE_INCLUDED_TOO"] = "happiest"
        result = session.run(
            sys.executable,
            "-c",
            "import os; print(os.environ)",
            silent=True,
        )
        assert "happy" in result
        assert "happier" in result
        assert "happiest" in result

    def test_no_included_invocation_env_vars_are_passed(self):
        session, runner = self.make_session_and_runner()
        runner.venv.env["I_SHOULD_NOT_BE_INCLUDED"] = "sad"
        runner.venv.env["AND_NEITHER_SHOULD_I"] = "unhappy"
        result = session.run(
            sys.executable,
            "-c",
            "import os; print(os.environ)",
            env={"I_SHOULD_BE_INCLUDED": "happy"},
            include_outer_env=False,
            silent=True,
        )
        assert "sad" not in result
        assert "unhappy" not in result
        assert "happy" in result

    def test_run_external_not_a_virtualenv(self):
        # Non-virtualenv sessions should always allow external programs.
        session, runner = self.make_session_and_runner()

        runner.venv = nox.virtualenv.PassthroughEnv()

        with mock.patch("nox.command.run", autospec=True) as run:
            session.run(sys.executable, "--version")

        run.assert_called_once_with(
            (sys.executable, "--version"),
            **run_with_defaults(external=True, env=mock.ANY),
        )

    def test_run_external_condaenv(self):
        # condaenv sessions should always allow conda.
        session, runner = self.make_session_and_runner()
        runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
        runner.venv.allowed_globals = ("conda",)
        runner.venv.env = {}
        runner.venv.bin_paths = ["/path/to/env/bin"]
        runner.venv.create.return_value = True

        with mock.patch("nox.command.run", autospec=True) as run:
            session.run("conda", "--version")

        run.assert_called_once_with(
            ("conda", "--version"),
            **run_with_defaults(
                external=True, env=mock.ANY, paths=["/path/to/env/bin"]
            ),
        )

    def test_run_external_with_error_on_external_run(self):
        session, runner = self.make_session_and_runner()

        runner.global_config.error_on_external_run = True

        with pytest.raises(nox.command.CommandFailed, match="External"):
            session.run(sys.executable, "--version")

    def test_run_external_with_error_on_external_run_condaenv(self):
        session, runner = self.make_session_and_runner()
        runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
        runner.venv.env = {}
        runner.venv.bin_paths = ["/path/to/env/bin"]

        runner.global_config.error_on_external_run = True

        with pytest.raises(nox.command.CommandFailed, match="External"):
            session.run(sys.executable, "--version")

    def test_run_install_bad_args(self):
        session, _ = self.make_session_and_runner()

        with pytest.raises(ValueError) as exc_info:
            session.run_install()

        exc_args = exc_info.value.args
        assert exc_args == ("At least one argument required to run_install().",)

    def test_run_no_install_passthrough(self):
        session, runner = self.make_session_and_runner()
        runner.venv = nox.virtualenv.PassthroughEnv()
        runner.global_config.no_install = True

        session.install("numpy")
        session.conda_install("numpy")

    def test_run_no_conda_install(self):
        session, runner = self.make_session_and_runner()

        with pytest.raises(ValueError, match="A session without a conda"):
            session.conda_install("numpy")

    def test_run_install_success(self):
        session, _ = self.make_session_and_runner()

        assert session.run_install(operator.add, 1300, 37) == 1337

    def test_run_install_install_only(self, caplog):
        session, runner = self.make_session_and_runner()
        runner.global_config.install_only = True

        assert session.run_install(operator.add, 23, 19) == 42

    @pytest.mark.parametrize(
        (
            "interrupt_timeout_setting",
            "terminate_timeout_setting",
            "interrupt_timeout_expected",
            "terminate_timeout_expected",
        ),
        [
            ("default", "default", 0.3, 0.2),
            (None, None, None, None),
            (0, 0, 0, 0),
            (1, 2, 1, 2),
        ],
    )
    def test_run_shutdown_process_timeouts(
        self,
        interrupt_timeout_setting,
        terminate_timeout_setting,
        interrupt_timeout_expected,
        terminate_timeout_expected,
    ):
        session, runner = self.make_session_and_runner()

        runner.venv = nox.virtualenv.PassthroughEnv()

        subp_popen_instance = mock.Mock()
        subp_popen_instance.communicate.side_effect = KeyboardInterrupt()
        with mock.patch(
            "nox.popen.shutdown_process", autospec=True
        ) as shutdown_process, mock.patch(
            "nox.popen.subprocess.Popen",
            new=mock.Mock(return_value=subp_popen_instance),
        ):
            shutdown_process.return_value = ("", "")

            timeout_kwargs = {}
            if interrupt_timeout_setting != "default":
                timeout_kwargs["interrupt_timeout"] = interrupt_timeout_setting
            if terminate_timeout_setting != "default":
                timeout_kwargs["terminate_timeout"] = terminate_timeout_setting

            with pytest.raises(KeyboardInterrupt):
                session.run(sys.executable, "--version", **timeout_kwargs)

        shutdown_process.assert_called_once_with(
            proc=mock.ANY,
            interrupt_timeout=interrupt_timeout_expected,
            terminate_timeout=terminate_timeout_expected,
        )

    @pytest.mark.parametrize(
        ("no_install", "reused", "run_called"),
        [
            (True, True, False),
            (True, False, True),
            (False, True, True),
            (False, False, True),
        ],
    )
    @pytest.mark.parametrize("run_install_func", ["run_always", "run_install"])
    def test_run_install_no_install(
        self, no_install, reused, run_called, run_install_func
    ):
        session, runner = self.make_session_and_runner()
        runner.global_config.no_install = no_install
        runner.venv._reused = reused

        with mock.patch.object(nox.command, "run") as run:
            run_install = getattr(session, run_install_func)
            run_install("python", "-m", "pip", "install", "requests")

        assert run.called is run_called

    def test_conda_install_bad_args(self):
        session, runner = self.make_session_and_runner()
        runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
        runner.venv.location = "dummy"

        with pytest.raises(ValueError, match="arg"):
            session.conda_install()

    def test_conda_install_bad_args_odd_nb_double_quotes(self):
        session, runner = self.make_session_and_runner()
        runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
        runner.venv.location = "./not/a/location"

        with pytest.raises(ValueError, match="odd number of quotes"):
            session.conda_install('a"a')

    def test_conda_install_bad_args_cannot_escape(self):
        session, runner = self.make_session_and_runner()
        runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
        runner.venv.location = "./not/a/location"

        with pytest.raises(ValueError, match="Cannot escape"):
            session.conda_install('a"o"<a')

    def test_conda_install_not_a_condaenv(self):
        session, runner = self.make_session_and_runner()

        runner.venv = None

        with pytest.raises(ValueError, match="conda environment"):
            session.conda_install()

    @pytest.mark.parametrize(
        "auto_offline", [False, True], ids="auto_offline={}".format
    )
    @pytest.mark.parametrize("offline", [False, True], ids="offline={}".format)
    @pytest.mark.parametrize("conda", ["conda", "mamba"], ids=str)
    @pytest.mark.parametrize(
        "channel",
        ["", "conda-forge", ["conda-forge", "bioconda"]],
        ids=["default", "conda-forge", "bioconda"],
    )
    def test_conda_install(self, auto_offline, offline, conda, channel):
        runner = nox.sessions.SessionRunner(
            name="test",
            signatures=["test"],
            func=mock.sentinel.func,
            global_config=_options.options.namespace(posargs=[]),
            manifest=mock.create_autospec(nox.manifest.Manifest),
        )
        runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
        runner.venv.location = "/path/to/conda/env"
        runner.venv.env = {}
        runner.venv.is_offline = lambda: offline
        runner.venv.conda_cmd = conda

        class SessionNoSlots(nox.sessions.Session):
            pass

        session = SessionNoSlots(runner=runner)

        with mock.patch.object(session, "_run", autospec=True) as run:
            args = ["--offline"] if auto_offline and offline else []
            if channel and isinstance(channel, str):
                args.append(f"--channel={channel}")
            else:
                args += [f"--channel={c}" for c in channel]
            session.conda_install(
                "requests", "urllib3", auto_offline=auto_offline, channel=channel
            )
            run.assert_called_once_with(
                conda,
                "install",
                "--yes",
                *args,
                "--prefix",
                "/path/to/conda/env",
                "requests",
                "urllib3",
                **_run_with_defaults(silent=True, external="error"),
            )

    @pytest.mark.parametrize(
        ("no_install", "reused", "run_called"),
        [
            (True, True, False),
            (True, False, True),
            (False, True, True),
            (False, False, True),
        ],
    )
    def test_conda_venv_reused_with_no_install(self, no_install, reused, run_called):
        session, runner = self.make_session_and_runner()

        runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
        runner.venv.location = "/path/to/conda/env"
        runner.venv.env = {}
        runner.venv.is_offline = lambda: True
        runner.venv.conda_cmd = "conda"

        runner.global_config.no_install = no_install
        runner.venv._reused = reused

        with mock.patch.object(nox.command, "run") as run:
            session.conda_install("baked beans", "eggs", "spam")

        assert run.called is run_called

    @pytest.mark.parametrize(
        "version_constraint",
        ["no", "yes", "already_dbl_quoted"],
        ids="version_constraint={}".format,
    )
    def test_conda_install_non_default_kwargs(self, version_constraint):
        runner = nox.sessions.SessionRunner(
            name="test",
            signatures=["test"],
            func=mock.sentinel.func,
            global_config=_options.options.namespace(posargs=[]),
            manifest=mock.create_autospec(nox.manifest.Manifest),
        )
        runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
        runner.venv.location = "/path/to/conda/env"
        runner.venv.env = {}
        runner.venv.is_offline = lambda: False
        runner.venv.conda_cmd = "conda"

        class SessionNoSlots(nox.sessions.Session):
            pass

        session = SessionNoSlots(runner=runner)

        if version_constraint == "no":
            pkg_requirement = passed_arg = "urllib3"
        elif version_constraint == "yes":
            pkg_requirement = "urllib3<1.25"
            passed_arg = f'"{pkg_requirement}"'
        elif version_constraint == "already_dbl_quoted":
            pkg_requirement = passed_arg = '"urllib3<1.25"'
        else:
            raise ValueError(version_constraint)

        with mock.patch.object(session, "_run", autospec=True) as run:
            session.conda_install("requests", pkg_requirement, silent=False)
            run.assert_called_once_with(
                "conda",
                "install",
                "--yes",
                "--prefix",
                "/path/to/conda/env",
                "requests",
                # this will be double quoted if unquoted constraint is present
                passed_arg,
                **_run_with_defaults(silent=False, external="error"),
            )

    def test_install_bad_args_no_arg(self):
        session, _ = self.make_session_and_runner()

        with pytest.raises(ValueError, match="arg"):
            session.install()

    def test_install_not_a_virtualenv(self):
        session, runner = self.make_session_and_runner()

        runner.venv = None

        with pytest.raises(ValueError, match="virtualenv"):
            session.install()

    def test_install(self):
        runner = nox.sessions.SessionRunner(
            name="test",
            signatures=["test"],
            func=mock.sentinel.func,
            global_config=_options.options.namespace(posargs=[]),
            manifest=mock.create_autospec(nox.manifest.Manifest),
        )
        runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
        runner.venv.env = {}
        runner.venv.venv_backend = "venv"

        class SessionNoSlots(nox.sessions.Session):
            pass

        session = SessionNoSlots(runner=runner)

        assert session.venv_backend == "venv"

        with mock.patch.object(session, "_run", autospec=True) as run:
            session.install("requests", "urllib3")
            run.assert_called_once_with(
                "python",
                "-m",
                "pip",
                "install",
                "requests",
                "urllib3",
                **_run_with_defaults(silent=True, external="error"),
            )

    def test_install_non_default_kwargs(self):
        runner = nox.sessions.SessionRunner(
            name="test",
            signatures=["test"],
            func=mock.sentinel.func,
            global_config=_options.options.namespace(posargs=[]),
            manifest=mock.create_autospec(nox.manifest.Manifest),
        )
        runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
        runner.venv.env = {}
        runner.venv.venv_backend = "venv"

        class SessionNoSlots(nox.sessions.Session):
            pass

        session = SessionNoSlots(runner=runner)

        with mock.patch.object(session, "_run", autospec=True) as run:
            session.install("requests", "urllib3", silent=False)
            run.assert_called_once_with(
                "python",
                "-m",
                "pip",
                "install",
                "requests",
                "urllib3",
                **_run_with_defaults(silent=False, external="error"),
            )

    def test_install_no_venv_failure(self):
        runner = nox.sessions.SessionRunner(
            name="test",
            signatures=["test"],
            func=mock.sentinel.func,
            global_config=_options.options.namespace(posargs=[]),
            manifest=mock.create_autospec(nox.manifest.Manifest),
        )
        runner.venv = mock.create_autospec(nox.virtualenv.PassthroughEnv)
        runner.venv.env = {}

        class SessionNoSlots(nox.sessions.Session):
            pass

        session = SessionNoSlots(runner=runner)

        with pytest.raises(
            ValueError,
            match=(
                r"use of session\.install\(\) is no longer allowed since"
                r" it would modify the global Python environment"
            ),
        ):
            session.install("requests", "urllib3")

    def test_notify(self):
        session, runner = self.make_session_and_runner()

        session.notify("other")

        runner.manifest.notify.assert_called_once_with("other", None)

        session.notify("other", posargs=["--an-arg"])

        runner.manifest.notify.assert_called_with("other", ["--an-arg"])

    def test_posargs_are_not_shared_between_sessions(self, monkeypatch, tmp_path):
        registry = {}
        monkeypatch.setattr("nox.registry._REGISTRY", registry)

        @nox.session(venv_backend="none")
        def test(session):
            session.posargs.extend(["-x"])

        @nox.session(venv_backend="none")
        def lint(session):
            if "-x" in session.posargs:
                raise RuntimeError("invalid option: -x")

        config = _options.options.namespace(posargs=[])
        manifest = nox.manifest.Manifest(registry, config)

        assert manifest["test"].execute()
        assert manifest["lint"].execute()

    def test_log(self, caplog):
        caplog.set_level(logging.INFO)
        session, _ = self.make_session_and_runner()

        session.log("meep")

        assert "meep" in caplog.text

    def test_warn(self, caplog):
        caplog.set_level(logging.WARNING)
        session, _ = self.make_session_and_runner()

        session.warn("meep")

        assert "meep" in caplog.text

    def test_debug(self, caplog):
        caplog.set_level(logging.DEBUG)
        session, _ = self.make_session_and_runner()

        session.debug("meep")

        assert "meep" in caplog.text

    def test_error(self, caplog):
        caplog.set_level(logging.ERROR)
        session, _ = self.make_session_and_runner()

        with pytest.raises(nox.sessions._SessionQuit, match="meep"):
            session.error("meep")

    def test_error_no_log(self):
        session, _ = self.make_session_and_runner()

        with pytest.raises(nox.sessions._SessionQuit):
            session.error()

    def test_skip_no_log(self):
        session, _ = self.make_session_and_runner()

        with pytest.raises(nox.sessions._SessionSkip):
            session.skip()

    @pytest.mark.parametrize(
        ("no_install", "reused", "run_called"),
        [
            (True, True, False),
            (True, False, True),
            (False, True, True),
            (False, False, True),
        ],
    )
    def test_session_venv_reused_with_no_install(self, no_install, reused, run_called):
        session, runner = self.make_session_and_runner()
        runner.global_config.no_install = no_install
        runner.venv._reused = reused

        with mock.patch.object(nox.command, "run") as run:
            session.install("eggs", "spam")

        assert run.called is run_called

    def test_install_uv(self):
        runner = nox.sessions.SessionRunner(
            name="test",
            signatures=["test"],
            func=mock.sentinel.func,
            global_config=_options.options.namespace(posargs=[]),
            manifest=mock.create_autospec(nox.manifest.Manifest),
        )
        runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
        runner.venv.env = {}
        runner.venv.venv_backend = "uv"

        class SessionNoSlots(nox.sessions.Session):
            pass

        session = SessionNoSlots(runner=runner)

        with mock.patch.object(session, "_run", autospec=True) as run:
            session.install("requests", "urllib3", silent=False)
            run.assert_called_once_with(
                "uv",
                "pip",
                "install",
                "requests",
                "urllib3",
                **_run_with_defaults(silent=False, external="error"),
            )

    def test_install_uv_command(self, monkeypatch):
        runner = nox.sessions.SessionRunner(
            name="test",
            signatures=["test"],
            func=mock.sentinel.func,
            global_config=_options.options.namespace(posargs=[]),
            manifest=mock.create_autospec(nox.manifest.Manifest),
        )
        runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
        runner.venv.env = {}
        runner.venv.venv_backend = "uv"

        class SessionNoSlots(nox.sessions.Session):
            pass

        session = SessionNoSlots(runner=runner)

        monkeypatch.setattr(nox.virtualenv, "UV", "/some/uv")
        monkeypatch.setattr(shutil, "which", lambda x, path=None: None)

        with mock.patch.object(nox.command, "run", autospec=True) as run:
            session.install("requests", "urllib3", silent=False)
            run.assert_called_once()

            ((call_args,), _) = run.call_args
            assert call_args == (
                "/some/uv",
                "pip",
                "install",
                "requests",
                "urllib3",
            )

        # user installs uv in the session venv
        monkeypatch.setattr(
            shutil, "which", lambda x, path="": path + "/uv" if x == "uv" else None
        )

        with mock.patch.object(nox.command, "run", autospec=True) as run:
            session.install("requests", "urllib3", silent=False)
            run.assert_called_once()

            ((call_args,), _) = run.call_args
            assert call_args == (
                "uv",
                "pip",
                "install",
                "requests",
                "urllib3",
            )

    def test___slots__(self):
        session, _ = self.make_session_and_runner()
        with pytest.raises(AttributeError):
            session.foo = "bar"
        with pytest.raises(AttributeError):
            session.quux  # noqa: B018

    def test___dict__(self):
        session, _ = self.make_session_and_runner()
        expected = {name: getattr(session, name) for name in session.__slots__}
        assert session.__dict__ == expected

    def test_first_arg_list(self):
        session, _ = self.make_session_and_runner()

        with pytest.raises(ValueError):
            session.run(["ls", "-al"])


class TestSessionRunner:
    def make_runner(self):
        func = mock.Mock()
        func.python = None
        func.venv_backend = None
        func.reuse_venv = False
        runner = nox.sessions.SessionRunner(
            name="test",
            signatures=["test(1, 2)"],
            func=func,
            global_config=_options.options.namespace(
                noxfile=os.path.join(os.getcwd(), "noxfile.py"),
                envdir="envdir",
                posargs=[],
                reuse_venv="no",
                error_on_missing_interpreters="CI" in os.environ,
            ),
            manifest=mock.create_autospec(nox.manifest.Manifest),
        )
        return runner

    def test_properties(self):
        runner = self.make_runner()

        assert runner.name == "test"
        assert runner.signatures == ["test(1, 2)"]
        assert runner.func is not None
        assert callable(runner.func)
        assert isinstance(runner.description, str)
        assert runner.global_config.posargs == []
        assert isinstance(runner.manifest, nox.manifest.Manifest)

    def test_str_and_friendly_name(self):
        runner = self.make_runner()
        runner.signatures = ["test(1, 2)", "test(3, 4)"]

        assert str(runner) == "Session(name=test, signatures=test(1, 2), test(3, 4))"
        assert runner.friendly_name == "test(1, 2)"

    def test_description_property_one_line(self):
        def foo():
            """Just one line"""

        runner = self.make_runner()
        runner.func = foo
        assert runner.description == "Just one line"

    def test_description_property_multi_line(self):
        def foo():
            """
            Multiline

            Extra description
            """

        runner = self.make_runner()
        runner.func = foo
        assert runner.description == "Multiline"

    def test_description_property_no_doc(self):
        def foo():
            pass

        runner = self.make_runner()
        runner.func = foo
        assert runner.description is None

    def test__create_venv_process_env(self):
        runner = self.make_runner()
        runner.func.python = False

        runner._create_venv()

        assert isinstance(runner.venv, nox.virtualenv.ProcessEnv)

    @mock.patch("nox.virtualenv.VirtualEnv.create", autospec=True)
    def test__create_venv(self, create):
        runner = self.make_runner()

        runner._create_venv()

        create.assert_called_once_with(runner.venv)
        assert isinstance(runner.venv, nox.virtualenv.VirtualEnv)
        assert runner.venv.location.endswith(os.path.join("envdir", "test-1-2"))
        assert runner.venv.interpreter is None
        assert runner.venv.reuse_existing is False

    @pytest.mark.parametrize(
        "create_method,venv_backend,expected_backend",
        [
            ("nox.virtualenv.VirtualEnv.create", None, nox.virtualenv.VirtualEnv),
            (
                "nox.virtualenv.VirtualEnv.create",
                "virtualenv",
                nox.virtualenv.VirtualEnv,
            ),
            ("nox.virtualenv.VirtualEnv.create", "venv", nox.virtualenv.VirtualEnv),
            pytest.param(
                "nox.virtualenv.CondaEnv.create",
                "conda",
                nox.virtualenv.CondaEnv,
                marks=has_conda,
            ),
        ],
    )
    def test__create_venv_options(self, create_method, venv_backend, expected_backend):
        runner = self.make_runner()
        runner.func.python = "coolpython"
        runner.func.reuse_venv = True
        runner.func.venv_backend = venv_backend

        with mock.patch(create_method, autospec=True) as create:
            runner._create_venv()

        create.assert_called_once_with(runner.venv)
        assert isinstance(runner.venv, expected_backend)
        assert runner.venv.interpreter == "coolpython"
        assert runner.venv.reuse_existing is True

    def test__create_venv_unexpected_venv_backend(self):
        runner = self.make_runner()
        runner.func.venv_backend = "somenewenvtool"
        with pytest.raises(ValueError, match="venv_backend"):
            runner._create_venv()

    @pytest.mark.parametrize(
        "venv_backend",
        ["uv|virtualenv", "conda|virtualenv", "mamba|conda|venv"],
    )
    def test_fallback_venv(self, venv_backend, monkeypatch):
        runner = self.make_runner()
        runner.func.venv_backend = venv_backend
        monkeypatch.setattr(
            nox.virtualenv,
            "OPTIONAL_VENVS",
            {"uv": False, "conda": False, "mamba": False},
        )
        with mock.patch("nox.virtualenv.VirtualEnv.create", autospec=True):
            runner._create_venv()
        assert runner.venv.venv_backend == venv_backend.split("|")[-1]

    @pytest.mark.parametrize(
        "venv_backend",
        [
            "uv|virtualenv|unknown",
            "conda|unknown|virtualenv",
            "virtualenv|venv",
            "conda|mamba",
        ],
    )
    def test_invalid_fallback_venv(self, venv_backend, monkeypatch):
        runner = self.make_runner()
        runner.func.venv_backend = venv_backend
        monkeypatch.setattr(
            nox.virtualenv,
            "OPTIONAL_VENVS",
            {"uv": False, "conda": False, "mamba": False},
        )
        with mock.patch(
            "nox.virtualenv.VirtualEnv.create", autospec=True
        ), pytest.raises(ValueError):
            runner._create_venv()

    @pytest.mark.parametrize(
        ("reuse_venv", "reuse_venv_func", "should_reuse"),
        [
            ("yes", None, True),
            ("yes", False, False),
            ("yes", True, True),
            ("no", None, False),
            ("no", False, False),
            ("no", True, True),
            ("always", None, True),
            ("always", False, True),
            ("always", True, True),
            ("never", None, False),
            ("never", False, False),
            ("never", True, False),
        ],
    )
    def test__reuse_venv_outcome(self, reuse_venv, reuse_venv_func, should_reuse):
        runner = self.make_runner()
        runner.func.reuse_venv = reuse_venv_func
        runner.global_config.reuse_venv = reuse_venv
        assert runner.reuse_existing_venv() == should_reuse

    def make_runner_with_mock_venv(self):
        runner = self.make_runner()
        runner._create_venv = mock.Mock()
        runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
        runner.venv.env = {}
        return runner

    def test_execute_noop_success(self, caplog):
        caplog.set_level(logging.DEBUG)

        runner = self.make_runner_with_mock_venv()

        result = runner.execute()

        assert result
        runner.func.assert_called_once_with(mock.ANY)
        assert "Running session test(1, 2)" in caplog.text

    def test_execute_quit(self):
        runner = self.make_runner_with_mock_venv()

        def func(session):
            session.error("meep")

        runner.func = func

        result = runner.execute()

        assert result.status == nox.sessions.Status.ABORTED

    def test_execute_skip(self):
        runner = self.make_runner_with_mock_venv()

        def func(session):
            session.skip("meep")

        runner.func = func

        result = runner.execute()

        assert result.status == nox.sessions.Status.SKIPPED

    def test_execute_with_manifest_null_session_func(self):
        runner = self.make_runner()
        runner.func = nox.manifest._null_session_func

        result = runner.execute()

        assert result.status == nox.sessions.Status.SKIPPED
        assert "no parameters" in result.reason

    def test_execute_skip_missing_interpreter(self, caplog, monkeypatch):
        # Important to have this first here as the runner will pick it up
        # to set default for --error-on-missing-interpreters
        monkeypatch.delenv("CI", raising=False)

        runner = self.make_runner_with_mock_venv()
        runner._create_venv.side_effect = nox.virtualenv.InterpreterNotFound("meep")

        result = runner.execute()

        assert result.status == nox.sessions.Status.SKIPPED
        assert "meep" in result.reason
        assert (
            "Missing interpreters will error by default on CI systems." in caplog.text
        )

    def test_execute_missing_interpreter_on_CI(self, monkeypatch):
        monkeypatch.setenv("CI", "True")
        runner = self.make_runner_with_mock_venv()
        runner._create_venv.side_effect = nox.virtualenv.InterpreterNotFound("meep")

        result = runner.execute()

        assert result.status == nox.sessions.Status.FAILED
        assert "meep" in result.reason

    def test_execute_error_missing_interpreter(self):
        runner = self.make_runner_with_mock_venv()
        runner.global_config.error_on_missing_interpreters = True
        runner._create_venv.side_effect = nox.virtualenv.InterpreterNotFound("meep")

        result = runner.execute()

        assert result.status == nox.sessions.Status.FAILED
        assert "meep" in result.reason

    def test_execute_failed(self):
        runner = self.make_runner_with_mock_venv()

        def func(session):
            raise nox.command.CommandFailed()

        runner.func = func

        result = runner.execute()

        assert result.status == nox.sessions.Status.FAILED

    def test_execute_interrupted(self):
        runner = self.make_runner_with_mock_venv()

        def func(session):
            raise KeyboardInterrupt()

        runner.func = func

        with pytest.raises(KeyboardInterrupt):
            runner.execute()

    def test_execute_exception(self):
        runner = self.make_runner_with_mock_venv()

        def func(session):
            raise ValueError("meep")

        runner.func = func

        result = runner.execute()

        assert result.status == nox.sessions.Status.FAILED

    def test_execute_check_env(self):
        runner = self.make_runner_with_mock_venv()

        def func(session):
            session.run(
                sys.executable,
                "-c",
                "import os; raise SystemExit(0 if"
                f' os.environ["NOX_CURRENT_SESSION"] == {session.name!r} else 0)',
            )

        runner.func = func

        result = runner.execute()

        assert result


class TestResult:
    def test_init(self):
        result = nox.sessions.Result(
            session=mock.sentinel.SESSION, status=mock.sentinel.STATUS
        )
        assert result.session == mock.sentinel.SESSION
        assert result.status == mock.sentinel.STATUS

    def test__bool_true(self):
        for status in (nox.sessions.Status.SUCCESS, nox.sessions.Status.SKIPPED):
            result = nox.sessions.Result(session=object(), status=status)
            assert bool(result)
            assert result.__bool__()
            assert result.__nonzero__()

    def test__bool_false(self):
        for status in (nox.sessions.Status.FAILED, nox.sessions.Status.ABORTED):
            result = nox.sessions.Result(session=object(), status=status)
            assert not bool(result)
            assert not result.__bool__()
            assert not result.__nonzero__()

    def test__imperfect(self):
        result = nox.sessions.Result(object(), nox.sessions.Status.SUCCESS)
        assert result.imperfect == "was successful"
        result = nox.sessions.Result(object(), nox.sessions.Status.FAILED)
        assert result.imperfect == "failed"
        result = nox.sessions.Result(
            object(), nox.sessions.Status.FAILED, reason="meep"
        )
        assert result.imperfect == "failed: meep"

    def test__log_success(self):
        result = nox.sessions.Result(object(), nox.sessions.Status.SUCCESS)
        with mock.patch.object(logger, "success") as success:
            result.log("foo")
            success.assert_called_once_with("foo")

    def test__log_warning(self):
        result = nox.sessions.Result(object(), nox.sessions.Status.SKIPPED)
        with mock.patch.object(logger, "warning") as warning:
            result.log("foo")
            warning.assert_called_once_with("foo")

    def test__log_error(self):
        result = nox.sessions.Result(object(), nox.sessions.Status.FAILED)
        with mock.patch.object(logger, "error") as error:
            result.log("foo")
            error.assert_called_once_with("foo")

    def test__serialize(self):
        result = nox.sessions.Result(
            session=argparse.Namespace(
                signatures=["siggy"], name="namey", func=mock.Mock()
            ),
            status=nox.sessions.Status.SUCCESS,
        )
        answer = result.serialize()
        assert answer["name"] == "namey"
        assert answer["result"] == "success"
        assert answer["result_code"] == 1
        assert answer["signatures"] == ["siggy"]
