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: add session sync for uv #857

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
26 changes: 26 additions & 0 deletions docs/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,32 @@ You can also pass environment variables:
See :func:`nox.sessions.Session.run` for more options and examples for running
programs.

Using uv to manage your project
-------------------------------

While the ``session.run_install`` can use ``uv`` as backend, it is also
possible to sync your project with ``session.sync()``. Nox will handle
the virtual env for you.

A sync will not remove extraneous packages present in the environment.

.. code-block:: python

@nox.session
def tests(session):
session.sync()
session.install("pytest")
session.run("pytest")

If you work with workspaces, install only given packages.

.. code-block:: python

@nox.session
def tests(session):
session.sync(package="mypackage")
session.run("mypackage")

Selecting which sessions to run
-------------------------------

Expand Down
98 changes: 94 additions & 4 deletions nox/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from typing import (
TYPE_CHECKING,
Any,
Literal,
NoReturn,
)

Expand Down Expand Up @@ -713,7 +714,7 @@ def install(
*args: str,
env: Mapping[str, str] | None = None,
include_outer_env: bool = True,
silent: bool | None = None,
silent: bool = True,
success_codes: Iterable[int] | None = None,
log: bool = True,
external: ExternalType | None = None,
Expand Down Expand Up @@ -781,9 +782,6 @@ def install(
if self._runner.global_config.no_install and venv._reused:
return

if silent is None:
silent = True

if isinstance(venv, VirtualEnv) and venv.venv_backend == "uv":
cmd = ["uv", "pip", "install"]
else:
Expand All @@ -803,6 +801,98 @@ def install(
terminate_timeout=terminate_timeout,
)

def sync(
self,
*args: str,
packages: Iterable[str] | None = None,
extras: Iterable[str] | Literal["all"] | None = None,
inexact: bool = True,
frozen: bool = True,
omit: Literal["dev", "non-dev"] | None = None,
env: Mapping[str, str] | None = None,
include_outer_env: bool = True,
silent: bool = True,
success_codes: Iterable[int] | None = None,
log: bool = True,
external: ExternalType | None = None,
stdout: int | IO[str] | None = None,
stderr: int | IO[str] | None = subprocess.STDOUT,
interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
) -> None:
"""Install invokes `uv`_ to sync packages inside of the session's
virtualenv.

:param packages: Sync for a specific package in the workspace.
:param extras: Include optional dependencies from the extra group name.
:param inexact: Do not remove extraneous packages present in the environment. ``True`` by default.
:param frozen: Sync without updating the `uv.lock` file. ``True`` by default.
:param omit: Omit dependencies.

Additional keyword args are the same as for :meth:`run`.

.. note::

Other then ``uv pip``, ``uv sync`` did not automatically install
packages into the virtualenv directory. To do so, it's mandatory
to setup ``UV_PROJECT_ENVIRONMENT`` to the virtual env folder. This
will be done in the sync command.

.. _uv: https://docs.astral.sh/uv/concepts/projects
"""
venv = self._runner.venv

if isinstance(venv, VirtualEnv) and venv.venv_backend == "uv":
overlay_env = env or {}
uv_venv = {"UV_PROJECT_ENVIRONMENT": venv.location}
env = {**uv_venv, **overlay_env}
elif not isinstance(venv, PassthroughEnv):
raise ValueError(
"A session without a uv environment can not install dependencies"
" with uv."
)

if self._runner.global_config.no_install and venv._reused:
return

cmd = ["uv", "sync"]

extraopts: list[str] = []
if isinstance(packages, list):
extraopts.extend(["--package", *packages])

if isinstance(extras, list):
extraopts.extend(["--extra", *extras])
elif extras == "all":
extraopts.append("--all-extras")

if frozen:
extraopts.append("--frozen")

if inexact:
extraopts.append("--inexact")

if omit == "dev":
extraopts.append("--no-dev")
elif omit == "non-dev":
extraopts.append("--only-dev")

self._run(
*cmd,
*args,
*extraopts,
env=env,
include_outer_env=include_outer_env,
external="error",
silent=silent,
success_codes=success_codes,
log=log,
stdout=stdout,
stderr=stderr,
interrupt_timeout=interrupt_timeout,
terminate_timeout=terminate_timeout,
)

def notify(
self,
target: str | SessionRunner,
Expand Down
34 changes: 34 additions & 0 deletions tests/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,40 @@ class SessionNoSlots(nox.sessions.Session):
"urllib3",
)

def test_sync_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"
runner.venv.location = "/project/.nox"

class SessionNoSlots(nox.sessions.Session):
pass

session = SessionNoSlots(runner=runner)

with mock.patch.object(session, "_run", autospec=True) as run:
session.sync(packages=["myproject"], silent=False)
run.assert_called_once_with(
"uv",
"sync",
"--package",
"myproject",
"--frozen",
"--inexact",
**_run_with_defaults(
silent=False,
external="error",
env={"UV_PROJECT_ENVIRONMENT": "/project/.nox"},
),
)

def test___slots__(self):
session, _ = self.make_session_and_runner()
with pytest.raises(AttributeError):
Expand Down
Loading