11"""Nox sessions."""
22
33import os
4+ import shlex
45import shutil
56import sys
67from pathlib import Path
78from tempfile import NamedTemporaryFile
9+ from textwrap import dedent
810
911import nox
1012from nox import Session
2527)
2628
2729
30+ def session_install_uv (
31+ session : Session ,
32+ install_project : bool = True ,
33+ install_dev : bool = False ,
34+ install_docs : bool = False ,
35+ ) -> None :
36+ """Install root project into the session's virtual environment using uv."""
37+ env = os .environ .copy ()
38+ env .update (
39+ {
40+ "UV_PROJECT_ENVIRONMENT" : session .virtualenv .location ,
41+ "UV_PYTHON" : sys .executable , # Force uv to use the session's interpreter
42+ }
43+ )
44+
45+ args = ["uv" , "sync" , "--frozen" ]
46+ if not install_project :
47+ args .append ("--no-install-project" )
48+ if not install_dev :
49+ args .append ("--no-dev" )
50+ if install_docs :
51+ args .extend (["--group" , "docs" ])
52+
53+ session .run_install (* args , silent = True , env = env )
54+
55+
56+ def session_install_uv_package (session : Session , packages : list [str ]) -> None :
57+ """Install packages into the session's virtual environment using uv lockfile."""
58+ env = os .environ .copy ()
59+ env .update (
60+ {
61+ "UV_PROJECT_ENVIRONMENT" : session .virtualenv .location ,
62+ "UV_PYTHON" : sys .executable ,
63+ }
64+ )
65+
66+ # Export requirements.txt to session temp dir using uv with locked dependencies
67+ requirements_tmp = str (Path (session .create_tmp ()) / "requirements.txt" )
68+ export_args = ["uv" , "export" , "--only-dev" , "--no-hashes" , "-o" , requirements_tmp ]
69+ session .run_install (* export_args , silent = True , env = env )
70+
71+ # Install requested packages with requirements.txt constraints
72+ session .install (* packages , "--constraint" , requirements_tmp )
73+
74+
75+ def activate_virtualenv_in_precommit_hooks (session : Session ) -> None :
76+ """Activate virtualenv in hooks installed by pre-commit.
77+
78+ This function patches git hooks installed by pre-commit to activate the
79+ session's virtual environment. This allows pre-commit to locate hooks in
80+ that environment when invoked from git.
81+
82+ Args:
83+ session: The Session object.
84+ """
85+ assert session .bin is not None # noqa: S101
86+
87+ # Only patch hooks containing a reference to this session's bindir. Support
88+ # quoting rules for Python and bash, but strip the outermost quotes so we
89+ # can detect paths within the bindir, like <bindir>/python.
90+ bindirs = [
91+ bindir [1 :- 1 ] if bindir [0 ] in "'\" " else bindir
92+ for bindir in (repr (session .bin ), shlex .quote (session .bin ))
93+ ]
94+
95+ virtualenv = session .env .get ("VIRTUAL_ENV" )
96+ if virtualenv is None :
97+ return
98+
99+ headers = {
100+ # pre-commit < 2.16.0
101+ "python" : f"""\
102+ import os
103+ os.environ["VIRTUAL_ENV"] = { virtualenv !r}
104+ os.environ["PATH"] = os.pathsep.join((
105+ { session .bin !r} ,
106+ os.environ.get("PATH", ""),
107+ ))
108+ """ ,
109+ # pre-commit >= 2.16.0
110+ "bash" : f"""\
111+ VIRTUAL_ENV={ shlex .quote (virtualenv )}
112+ PATH={ shlex .quote (session .bin )} "{ os .pathsep } $PATH"
113+ """ ,
114+ # pre-commit >= 2.17.0 on Windows forces sh shebang
115+ "/bin/sh" : f"""\
116+ VIRTUAL_ENV={ shlex .quote (virtualenv )}
117+ PATH={ shlex .quote (session .bin )} "{ os .pathsep } $PATH"
118+ """ ,
119+ }
120+
121+ hookdir = Path (".git" ) / "hooks"
122+ if not hookdir .is_dir ():
123+ return
124+
125+ for hook in hookdir .iterdir ():
126+ if hook .name .endswith (".sample" ) or not hook .is_file ():
127+ continue
128+
129+ if not hook .read_bytes ().startswith (b"#!" ):
130+ continue
131+
132+ text = hook .read_text ()
133+
134+ if not any (
135+ Path ("A" ) == Path ("a" ) and bindir .lower () in text .lower () or bindir in text
136+ for bindir in bindirs
137+ ):
138+ continue
139+
140+ lines = text .splitlines ()
141+
142+ for executable , header in headers .items ():
143+ if executable in lines [0 ].lower ():
144+ lines .insert (1 , dedent (header ))
145+ hook .write_text ("\n " .join (lines ))
146+ break
147+
148+
28149@session (name = "pre-commit" , python = python_versions [0 ])
29150def precommit (session : Session ) -> None :
30151 """Lint using pre-commit."""
152+ session_install_uv (session , install_dev = True )
31153 args = session .posargs or [
32154 "run" ,
33155 "--all-files" ,
34156 "--hook-stage=manual" ,
35157 "--show-diff-on-failure" ,
36158 ]
37- session .run (
38- "uv" ,
39- "pip" ,
40- "install" ,
41- "black" ,
42- "darglint" ,
43- "flake8" ,
44- "flake8-bandit" ,
45- "flake8-bugbear" ,
46- "flake8-docstrings" ,
47- "flake8-rst-docstrings" ,
48- "isort" ,
49- "pep8-naming" ,
50- "pre-commit" ,
51- "pre-commit-hooks" ,
52- "pyupgrade" ,
53- external = True ,
54- )
159+ session_install_uv_package (session , ["pre-commit" ])
55160 session .run ("pre-commit" , * args )
161+ if args and args [0 ] == "install" :
162+ activate_virtualenv_in_precommit_hooks (session )
56163
57164
58165@session (python = python_versions [0 ])
59166def safety (session : Session ) -> None :
60167 """Scan dependencies for insecure packages."""
61168 with NamedTemporaryFile (delete = False ) as requirements :
62- session .run (
63- "uv" ,
64- "pip" ,
65- "compile" ,
66- "pyproject.toml" ,
67- "--output-file" ,
68- requirements .name ,
69- external = True ,
70- )
71- session .run ("uv" , "pip" , "install" , "safety" , external = True )
169+ session_install_uv (session , install_dev = True )
170+ session_install_uv_package (session , ["safety" ])
72171 # TODO(Altay): Remove the CVE ignore once its resolved.
73172 # It's not critical, so ignoring now.
74173 ignore = ["70612" ]
@@ -88,8 +187,8 @@ def safety(session: Session) -> None:
88187def mypy (session : Session ) -> None :
89188 """Type-check using mypy."""
90189 args = session .posargs or ["src" , "tests" , "docs/conf.py" ]
91- session . run_always ( "uv" , "pip" , "install" , "-e" , "." , external = True )
92- session . run ( "uv" , "pip" , "install" , " mypy" , "pytest" , external = True )
190+ session_install_uv ( session , install_dev = True )
191+ session_install_uv_package ( session , [ " mypy" , "pytest" ] )
93192 session .run ("mypy" , * args )
94193 if not session .posargs :
95194 session .run ("mypy" , f"--python-executable={ sys .executable } " , "noxfile.py" )
@@ -98,7 +197,10 @@ def mypy(session: Session) -> None:
98197@session (python = python_versions )
99198def tests (session : Session ) -> None :
100199 """Run the test suite."""
101- session .run_always ("uv" , "pip" , "install" , "-e" , ".[cloud]" , external = True )
200+ session_install_uv (session , install_project = True , install_dev = True )
201+ session_install_uv_package (
202+ session , ["coverage" , "pytest" , "pygments" , "pytest-dependency" , "s3fs" ]
203+ )
102204 session .run (
103205 "uv" ,
104206 "pip" ,
@@ -122,7 +224,8 @@ def coverage(session: Session) -> None:
122224 """Produce the coverage report."""
123225 args = session .posargs or ["report" ]
124226
125- session .run ("uv" , "pip" , "install" , "coverage[toml]" , external = True )
227+ session_install_uv (session , install_project = True , install_dev = True )
228+ session_install_uv_package (session , ["coverage" ])
126229
127230 if not session .posargs and any (Path ().glob (".coverage.*" )):
128231 session .run ("coverage" , "combine" , external = True )
@@ -133,10 +236,8 @@ def coverage(session: Session) -> None:
133236@session (python = python_versions [0 ])
134237def typeguard (session : Session ) -> None :
135238 """Runtime type checking using Typeguard."""
136- session .run_always ("uv" , "pip" , "install" , "-e" , "." , external = True )
137- session .run (
138- "uv" , "pip" , "install" , "pytest" , "typeguard" , "pygments" , external = True
139- )
239+ session_install_uv (session , install_project = True , install_dev = True )
240+ session_install_uv_package (session , ["pytest" , "typeguard" , "pygments" ])
140241 session .run ("pytest" , f"--typeguard-packages={ package } " , * session .posargs )
141242
142243
@@ -150,8 +251,8 @@ def xdoctest(session: Session) -> None:
150251 if "FORCE_COLOR" in os .environ :
151252 args .append ("--colored=1" )
152253
153- session . run_always ( "uv" , "pip" , "install" , "-e" , "." , external = True )
154- session . run ( "uv" , "pip" , "install" , " xdoctest[colors]" , external = True )
254+ session_install_uv ( session , install_project = True , install_dev = True )
255+ session_install_uv_package ( session , [ " xdoctest" ] )
155256 session .run ("python" , "-m" , "xdoctest" , * args )
156257
157258
@@ -162,18 +263,17 @@ def docs_build(session: Session) -> None:
162263 if not session .posargs and "FORCE_COLOR" in os .environ :
163264 args .insert (0 , "--color" )
164265
165- session .run_always ("uv" , "pip" , "install" , "-e" , "." , external = True )
166- session .run (
167- "uv" ,
168- "pip" ,
169- "install" ,
170- "sphinx" ,
171- "sphinx-click" ,
172- "sphinx-copybutton" ,
173- "furo" ,
174- "myst-nb" ,
175- "linkify-it-py" ,
176- external = True ,
266+ session_install_uv (session , install_project = True , install_dev = True )
267+ session_install_uv_package (
268+ session ,
269+ [
270+ "sphinx" ,
271+ "sphinx-click" ,
272+ "sphinx-copybutton" ,
273+ "furo" ,
274+ "myst-nb" ,
275+ "linkify-it-py" ,
276+ ],
177277 )
178278
179279 build_dir = Path ("docs" , "_build" )
@@ -187,19 +287,18 @@ def docs_build(session: Session) -> None:
187287def docs (session : Session ) -> None :
188288 """Build and serve the documentation with live reloading on file changes."""
189289 args = session .posargs or ["--open-browser" , "docs" , "docs/_build" ]
190- session .run_always ("uv" , "pip" , "install" , "-e" , "." , external = True )
191- session .run (
192- "uv" ,
193- "pip" ,
194- "install" ,
195- "sphinx" ,
196- "sphinx-autobuild" ,
197- "sphinx-click" ,
198- "sphinx-copybutton" ,
199- "furo" ,
200- "myst-nb" ,
201- "linkify-it-py" ,
202- external = True ,
290+ session_install_uv (session , install_project = True , install_dev = True )
291+ session_install_uv_package (
292+ session ,
293+ [
294+ "sphinx" ,
295+ "sphinx-autobuild" ,
296+ "sphinx-click" ,
297+ "sphinx-copybutton" ,
298+ "furo" ,
299+ "myst-nb" ,
300+ "linkify-it-py" ,
301+ ],
203302 )
204303
205304 build_dir = Path ("docs" , "_build" )
0 commit comments