Skip to content

Commit

Permalink
feat: add session sync for uv
Browse files Browse the repository at this point in the history
  • Loading branch information
Wurstnase committed Oct 6, 2024
1 parent d7072e3 commit 4f4d4f3
Show file tree
Hide file tree
Showing 3 changed files with 154 additions and 4 deletions.
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

0 comments on commit 4f4d4f3

Please sign in to comment.