Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added venv_location option to specify virtualenv location #785

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
12 changes: 11 additions & 1 deletion docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,24 @@ 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

@nox.session(venv_params=['--no-download'])
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
-------------------------------
Expand Down
29 changes: 4 additions & 25 deletions docs/cookbook.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down
6 changes: 5 additions & 1 deletion nox/_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 [])

Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down
5 changes: 4 additions & 1 deletion nox/_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]"]
20 changes: 18 additions & 2 deletions nox/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -212,15 +216,19 @@ 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

if self._config.extra_pythons:
# 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
Expand All @@ -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
Expand All @@ -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:
Expand Down
14 changes: 12 additions & 2 deletions nox/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand All @@ -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]:
...
Expand All @@ -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."""
Expand All @@ -74,6 +76,7 @@ def session_decorator(
name=name,
venv_backend=venv_backend,
venv_params=venv_params,
venv_location=venv_location,
tags=tags,
)

Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion nox/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion nox/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. "
)
Expand Down
24 changes: 24 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 66 additions & 0 deletions tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
WARN_PYTHONS_IGNORED,
KeywordLocals,
Manifest,
NoVenvLocationWithParametrize,
_normalize_arg,
_normalized_session_match,
_null_session_func,
Expand Down Expand Up @@ -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",
[
Expand Down Expand Up @@ -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())

Expand All @@ -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):
Expand Down
Loading
Loading