diff --git a/docs/config.rst b/docs/config.rst index 74bb7836..b6e04799 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -175,7 +175,7 @@ You are not limited to virtualenv, there is a selection of backends you can choo def tests(session): pass -Finally, custom backend parameters are supported: +Custom backend parameters are supported: .. code-block:: python @@ -183,6 +183,16 @@ Finally, custom backend parameters are supported: def tests(session): pass +Finally, you can specify the exact location of an environment: + +.. code-block:: python + + @nox.session(venv_location=".venv") + def dev(session): + pass + +This places the environment in the folder ``./.venv`` instead of the default ``./.nox/dev``. + Passing arguments into sessions ------------------------------- diff --git a/docs/cookbook.rst b/docs/cookbook.rst index 26f45d59..39e2fa80 100644 --- a/docs/cookbook.rst +++ b/docs/cookbook.rst @@ -47,33 +47,12 @@ Enter the ``dev`` nox session: # so it's not run twice accidentally nox.options.sessions = [...] # Sessions other than 'dev' - # this VENV_DIR constant specifies the name of the dir that the `dev` - # session will create, containing the virtualenv; - # the `resolve()` makes it portable - VENV_DIR = pathlib.Path('./.venv').resolve() + VENV_DIR = "./.venv" - @nox.session + @nox.session(venv_location=VENV_DIR) def dev(session: nox.Session) -> None: - """ - Sets up a python development environment for the project. - - This session will: - - Create a python virtualenv for the session - - Install the `virtualenv` cli tool into this environment - - Use `virtualenv` to create a global project virtual environment - - Invoke the python interpreter from the global project environment to install - the project and all it's development dependencies. - """ - - session.install("virtualenv") - # the VENV_DIR constant is explained above - session.run("virtualenv", os.fsdecode(VENV_DIR), silent=True) - - python = os.fsdecode(VENV_DIR.joinpath("bin/python")) - - # Use the venv's interpreter to install the project along with - # all it's dev dependencies, this ensures it's installed in the right way - session.run(python, "-m", "pip", "install", "-e", ".[dev]", external=True) + """Sets up a python development environment for the project.""" + session.install("-e", ".[dev]") With this, a user can simply run ``nox -s dev`` and have their entire environment set up automatically! diff --git a/nox/_decorators.py b/nox/_decorators.py index aaef96a9..31efd4d5 100644 --- a/nox/_decorators.py +++ b/nox/_decorators.py @@ -64,9 +64,10 @@ def __init__( func: Callable[..., Any], python: _typing.Python = None, reuse_venv: bool | None = None, - name: str | None = None, + name: _typing.StrPath | None = None, venv_backend: Any = None, venv_params: Any = None, + venv_location: _typing.StrPath | None = None, should_warn: Mapping[str, Any] | None = None, tags: Sequence[str] | None = None, ) -> None: @@ -76,6 +77,7 @@ def __init__( self.name = name self.venv_backend = venv_backend self.venv_params = venv_params + self.venv_location = venv_location self.should_warn = dict(should_warn or {}) self.tags = list(tags or []) @@ -92,6 +94,7 @@ def copy(self, name: str | None = None) -> Func: name, self.venv_backend, self.venv_params, + self.venv_location, self.should_warn, self.tags, ) @@ -123,6 +126,7 @@ def __init__(self, func: Func, param_spec: Param) -> None: None, func.venv_backend, func.venv_params, + func.venv_location, func.should_warn, func.tags, ) diff --git a/nox/_typing.py b/nox/_typing.py index ac2d5ca8..68c7a6e7 100644 --- a/nox/_typing.py +++ b/nox/_typing.py @@ -14,8 +14,11 @@ from __future__ import annotations -__all__ = ["Python"] +import os + +__all__ = ["Python", "StrPath"] from typing import Sequence, Union Python = Union[str, Sequence[str], bool, None] +StrPath = Union[str, "os.PathLike[str]"] diff --git a/nox/manifest.py b/nox/manifest.py index fa9f7978..4f6fecf0 100644 --- a/nox/manifest.py +++ b/nox/manifest.py @@ -32,6 +32,10 @@ def _unique_list(*args: str) -> list[str]: return list(OrderedDict.fromkeys(args)) +class NoVenvLocationWithParametrize(Exception): + """Cannot specify ``venv_location`` with parametrized values.""" + + class Manifest: """Session manifest. @@ -212,7 +216,7 @@ def make_session( ) if backend == "none" and isinstance(func.python, (list, tuple, set)): # we can not log a warning here since the session is maybe deselected. - # instead let's set a flag, to warn later when session is actually run. + # instead let's set a flag, to warn later when session is actually run func.should_warn[WARN_PYTHONS_IGNORED] = func.python func.python = False @@ -220,7 +224,11 @@ def make_session( # If extra python is provided, expand the func.python list to # include additional python interpreters extra_pythons: list[str] = self._config.extra_pythons - if isinstance(func.python, (list, tuple, set)): + + if func.venv_location is not None: + # special case. If set venv_location, then ignore extra pythons + func.should_warn[WARN_PYTHONS_IGNORED] = extra_pythons + elif isinstance(func.python, (list, tuple, set)): func.python = _unique_list(*func.python, *extra_pythons) elif not multi and func.python: # If this is multi, but there is only a single interpreter, it @@ -238,6 +246,10 @@ def make_session( # If the func has the python attribute set to a list, we'll need # to expand them. if isinstance(func.python, (list, tuple, set)): + if func.venv_location is not None: + msg = f"Cannot specify venv_location={func.venv_location} with multiple pythons" + raise NoVenvLocationWithParametrize(msg) + for python in func.python: single_func = func.copy() single_func.python = python @@ -258,6 +270,10 @@ def make_session( # Since this function is parametrized, we need to add a distinct # session for each permutation. + if func.venv_location is not None: + msg = f"Cannot specify venv_location={func.venv_location} with parametrized session." + raise NoVenvLocationWithParametrize(msg) + parametrize = func.parametrize calls = Call.generate_calls(func, parametrize) for call in calls: diff --git a/nox/registry.py b/nox/registry.py index 60765ae3..4dfa68f2 100644 --- a/nox/registry.py +++ b/nox/registry.py @@ -21,7 +21,7 @@ from typing import Any, Callable, TypeVar, overload from ._decorators import Func -from ._typing import Python +from ._typing import Python, StrPath F = TypeVar("F", bound=Callable[..., Any]) @@ -42,6 +42,7 @@ def session_decorator( name: str | None = ..., venv_backend: Any | None = ..., venv_params: Any | None = ..., + venv_location: StrPath | None = ..., tags: Sequence[str] | None = ..., ) -> Callable[[F], F]: ... @@ -55,6 +56,7 @@ def session_decorator( name: str | None = None, venv_backend: Any | None = None, venv_params: Any | None = None, + venv_location: StrPath | None = None, tags: Sequence[str] | None = None, ) -> F | Callable[[F], F]: """Designate the decorated function as a session.""" @@ -74,6 +76,7 @@ def session_decorator( name=name, venv_backend=venv_backend, venv_params=venv_params, + venv_location=venv_location, tags=tags, ) @@ -88,7 +91,14 @@ def session_decorator( final_name = name or func.__name__ fn = Func( - func, python, reuse_venv, final_name, venv_backend, venv_params, tags=tags + func, + python, + reuse_venv, + final_name, + venv_backend, + venv_params, + venv_location=venv_location, + tags=tags, ) _REGISTRY[final_name] = fn return fn diff --git a/nox/sessions.py b/nox/sessions.py index a98916f4..66440b8b 100644 --- a/nox/sessions.py +++ b/nox/sessions.py @@ -754,7 +754,12 @@ def tags(self) -> list[str]: @property def envdir(self) -> str: - return _normalize_path(self.global_config.envdir, self.friendly_name) + if self.func.venv_location: + return os.path.expanduser(self.func.venv_location) + return _normalize_path( + self.global_config.envdir, + self.friendly_name, + ) def _create_venv(self) -> None: backend = ( @@ -769,6 +774,11 @@ def _create_venv(self) -> None: reuse_existing = self.reuse_existing_venv() + if self.func.venv_location: + logger.warning( + f"Using user defined venv_location={self.func.venv_location} for virtual environment." + ) + if backend is None or backend in {"virtualenv", "venv", "uv"}: self.venv = VirtualEnv( self.envdir, diff --git a/nox/tasks.py b/nox/tasks.py index 595a4056..1fd6497c 100644 --- a/nox/tasks.py +++ b/nox/tasks.py @@ -330,8 +330,13 @@ def run_manifest(manifest: Manifest, global_config: Namespace) -> list[Result]: for session in manifest: # possibly raise warnings associated with this session if WARN_PYTHONS_IGNORED in session.func.should_warn: + msg = ( + "venv_backend='none'" + if session.func.venv_location is None + else f"venv_location={session.func.venv_location}" + ) logger.warning( - f"Session {session.name} is set to run with venv_backend='none', " + f"Session {session.name} is set to run with {msg}, " "IGNORING its" f" python={session.func.should_warn[WARN_PYTHONS_IGNORED]} parametrization. " ) diff --git a/noxfile.py b/noxfile.py index a7a665c7..10f86b93 100644 --- a/noxfile.py +++ b/noxfile.py @@ -192,3 +192,27 @@ def github_actions_default_tests(session: nox.Session) -> None: def github_actions_all_tests(session: nox.Session) -> None: """Check all versions installed by the nox GHA Action""" _check_python_version(session) + + +@nox.session(venv_location=".venv-dev") +def dev(session: nox.Session) -> None: + """Create development environment `./.venv` using `nox -s dev`""" + session.install("-r", "requirements-dev.txt") + + +# # # @nox.session(venv_location=".venv", python=["3.10", "3.11"]) +# @nox.session(venv_location=".venv-tmp") +# @nox.parametrize("thing", [1, 2]) +# def tmp(session: nox.Session, thing: int) -> None: +# """Create development environment `./.venv` using `nox -s dev`""" + + +# @nox.session(python="3.8", venv_backend=None) +# def tmpnox(session: nox.Session) -> None: +# print(session.virtualenv.venv_backend) +# print(nox.options.default_venv_backend) + + +# @nox.session(venv_location="hello", python="3.8") +# def hello(session: nox.Session) -> None: +# pass diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 5452288b..cb1343b2 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -26,6 +26,7 @@ WARN_PYTHONS_IGNORED, KeywordLocals, Manifest, + NoVenvLocationWithParametrize, _normalize_arg, _normalized_session_match, _null_session_func, @@ -288,6 +289,56 @@ def session_func(): assert expected == [session.func.python for session in manifest._all_sessions] +@pytest.mark.parametrize( + "python,extra_pythons,expected", + [ + (None, [], [None]), + (None, ["3.8"], [None]), + (None, ["3.8", "3.9"], [None]), + (False, [], [False]), + (False, ["3.8"], [False]), + (False, ["3.8", "3.9"], [False]), + ("3.5", [], ["3.5"]), + ("3.5", ["3.8"], ["3.5"]), + ("3.5", ["3.8", "3.9"], ["3.5"]), + (["3.5", "3.9"], [], "error"), + (["3.5", "3.9"], ["3.8"], "error"), + (["3.5", "3.9"], ["3.8", "3.4"], "error"), + (["3.5", "3.9"], ["3.5", "3.9"], "error"), + ], +) +def test_extra_pythons_with_venv_location(python, extra_pythons, expected): + cfg = create_mock_config() + cfg.extra_pythons = extra_pythons + + manifest = Manifest({}, cfg) + + def session_func(): + pass + + func = Func(session_func, python=python, venv_location="my-location") + + if expected == "error": + with pytest.raises(NoVenvLocationWithParametrize): + for session in manifest.make_session("my_session", func): + manifest.add_session(session) + + else: + for session in manifest.make_session("my_session", func): + manifest.add_session(session) + + assert len(manifest._all_sessions) == 1 + + assert expected == [session.func.python for session in manifest._all_sessions] + + session = manifest._all_sessions[0] + + if extra_pythons: + assert session.func.should_warn == {WARN_PYTHONS_IGNORED: extra_pythons} + else: + assert session.func.should_warn == {} + + @pytest.mark.parametrize( "python,force_pythons,expected", [ @@ -339,6 +390,20 @@ def my_session(session, param): assert len(manifest) == 3 +def test_add_session_parametrized_venv_location(): + manifest = Manifest({}, create_mock_config()) + + # Define a session with parameters. + @nox.parametrize("param", ("a", "b", "c")) + def my_session(session, param): + pass + + func = Func(my_session, python=None, venv_location="my-location") + + with pytest.raises(NoVenvLocationWithParametrize): + manifest.make_session("my_session", func) + + def test_add_session_parametrized_multiple_pythons(): manifest = Manifest({}, create_mock_config()) @@ -365,6 +430,7 @@ def my_session(session, param): my_session.python = None my_session.venv_backend = None + my_session.venv_location = None # Add the session to the manifest. for session in manifest.make_session("my_session", my_session): diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 24ebd635..eba2ba0e 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -34,6 +34,15 @@ from nox.logger import logger +@pytest.fixture() +def change_to_tmp_path(tmp_path): + old_cwd = Path.cwd() + os.chdir(tmp_path) + yield tmp_path + # Cleanup? + os.chdir(old_cwd) + + def test__normalize_path(): envdir = "envdir" normalize = nox.sessions._normalize_path @@ -62,8 +71,8 @@ def test__normalize_path_give_up(): class TestSession: - def make_session_and_runner(self): - func = mock.Mock(spec=["python"], python="3.7") + def make_session_and_runner(self, venv_location=None): + func = mock.Mock(spec=["python"], python="3.7", venv_location=venv_location) runner = nox.sessions.SessionRunner( name="test", signatures=["test"], @@ -100,6 +109,57 @@ def test_create_tmp_twice(self): assert session.env["TMPDIR"] == os.path.abspath(tmpdir) assert tmpdir.startswith(root) + @pytest.mark.parametrize("pre_run", [0, 1]) + def test_create_tmp_with_venv_location(self, pre_run): + # for testing, also set envdir + with tempfile.TemporaryDirectory() as new_location: + session, runner = self.make_session_and_runner( + venv_location=os.path.join(new_location, "my-location") + ) + # for testing, also set envdir + with tempfile.TemporaryDirectory() as root: + runner.global_config.envdir = root + for _ in range(pre_run): + session.create_tmp() + + tmpdir = session.create_tmp() + + assert tmpdir == os.path.join(new_location, "my-location", "tmp") + assert os.path.abspath(tmpdir) == os.path.join( + new_location, "my-location", "tmp" + ) + assert session.env["TMPDIR"] == os.path.abspath(tmpdir) + + @pytest.mark.parametrize("pre_run", [0, 1]) + def test_create_tmp_with_venv_location2(self, change_to_tmp_path, pre_run): + session, runner = self.make_session_and_runner(venv_location="my-location") + # for testing, also set envdir + with tempfile.TemporaryDirectory() as root: + runner.global_config.envdir = root + for _ in range(pre_run): + session.create_tmp() + + tmpdir = session.create_tmp() + + assert tmpdir == os.path.join("my-location", "tmp") + assert os.path.abspath(tmpdir) == os.path.join( + change_to_tmp_path, "my-location", "tmp" + ) + assert session.env["TMPDIR"] == os.path.abspath(tmpdir) + + @pytest.mark.parametrize( + ("venv_location", "envdir"), + [ + ("my-location", "my-location"), + ("~/my-location", os.path.expanduser("~/my-location")), + (Path("my-location"), "my-location"), + (Path("~/my-location"), os.path.expanduser("~/my-location")), + ], + ) + def test_envdir(self, venv_location, envdir): + session, runner = self.make_session_and_runner(venv_location=venv_location) + assert runner.envdir == envdir + def test_properties(self): session, runner = self.make_session_and_runner() with tempfile.TemporaryDirectory() as root: @@ -849,6 +909,7 @@ def make_runner(self): func.python = None func.venv_backend = None func.reuse_venv = False + func.venv_location = None runner = nox.sessions.SessionRunner( name="test", signatures=["test(1, 2)"], @@ -930,6 +991,9 @@ def test__create_venv(self, create): assert runner.venv.interpreter is None assert runner.venv.reuse_existing is False + @pytest.mark.parametrize( + "venv_location", [None, "my-location", Path("my-location")] + ) @pytest.mark.parametrize( "create_method,venv_backend,expected_backend", [ @@ -943,11 +1007,14 @@ def test__create_venv(self, create): ("nox.virtualenv.CondaEnv.create", "conda", nox.virtualenv.CondaEnv), ], ) - def test__create_venv_options(self, create_method, venv_backend, expected_backend): + def test__create_venv_options( + self, venv_location, 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 + runner.func.venv_location = venv_location with mock.patch(create_method, autospec=True) as create: runner._create_venv() @@ -957,6 +1024,18 @@ def test__create_venv_options(self, create_method, venv_backend, expected_backen assert runner.venv.interpreter == "coolpython" assert runner.venv.reuse_existing is True + location_name = ( + os.path.expanduser(venv_location) + if venv_location + else nox.sessions._normalize_path( + runner.global_config.envdir, runner.friendly_name + ) + ) + + assert runner.venv.location_name == location_name + assert runner.venv.location == os.path.abspath(location_name) + assert runner.envdir == location_name + def test__create_venv_unexpected_venv_backend(self): runner = self.make_runner() runner.func.venv_backend = "somenewenvtool" diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 947239ed..8368e599 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -38,6 +38,7 @@ def session_func(): session_func.python = None session_func.venv_backend = None +session_func.venv_location = None session_func.should_warn = {} session_func.tags = [] @@ -56,9 +57,23 @@ def session_func_venv_pythons_warning(): session_func_venv_pythons_warning.python = ["3.7"] session_func_venv_pythons_warning.venv_backend = "none" +session_func_venv_pythons_warning.venv_location = None session_func_venv_pythons_warning.should_warn = {WARN_PYTHONS_IGNORED: ["3.7"]} +# Mimic passing --extra-python ["3.8", "3.9"] +def session_func_venv_pythons_warning_venv_location(): + pass + + +session_func_venv_pythons_warning_venv_location.python = "3.7" +session_func_venv_pythons_warning_venv_location.venv_backend = None +session_func_venv_pythons_warning_venv_location.venv_location = "my-location" +session_func_venv_pythons_warning_venv_location.should_warn = { + WARN_PYTHONS_IGNORED: ["3.8", "3.9"] +} + + def test_load_nox_module(): config = _options.options.namespace(noxfile=os.path.join(RESOURCES, "noxfile.py")) noxfile_module = tasks.load_nox_module(config) @@ -527,7 +542,9 @@ def test_verify_manifest_list(capsys): assert "Please select a session" in capsys.readouterr().out -@pytest.mark.parametrize("with_warnings", [False, True], ids="with_warnings={}".format) +@pytest.mark.parametrize( + "with_warnings", [False, True, "venv_location"], ids="with_warnings={}".format +) def test_run_manifest(with_warnings): # Set up a valid manifest. config = _options.options.namespace(stop_on_first_error=False) @@ -544,7 +561,10 @@ def test_run_manifest(with_warnings): session=mock_session, status=sessions.Status.SUCCESS ) # we need the should_warn attribute, add some func - if with_warnings: + if with_warnings == "venv_location": + mock_session.name = "hello" + mock_session.func = session_func_venv_pythons_warning_venv_location + elif with_warnings: mock_session.name = "hello" mock_session.func = session_func_venv_pythons_warning else: