Repository: wntrblm/nox (python)

Issue:
Wrong starting directory when noxfile.py is a symlink
I have a project where I want the noxfille to be a symlink to a different shared file.  When I do this, it throws off the paths b/c nox follows the symlink and starts in that directory instead of starting in the directory where the symlink is.

```
 ❯ ls -l noxfile.py 
lrwxrwxrwx 1 rsyring rsyring 21 May 15 23:09 noxfile.py -> ./template/noxfile.py

 ❯ nox -s cwd
nox > Running session cwd
nox > Creating virtual environment (uv) using python3 in .nox/cwd
cwd is: /home/rsyring/projects/copier-py-package/template
```

Was this an intentional design decision?  If not, how about starting where the symlink is instead (i.e. don't follow it)?

Thanks for your work on this project and consideration.  


Pull request description:
fix: support noxfile being a symlink
Fix #828.

If you need the non-symlinked file, it's easy to get with `__file__`, as can be seen in the tests.

Patch:
diff --git a/nox/__init__.py b/nox/__init__.py
index 4664edc..8121e62 100644
--- a/nox/__init__.py
+++ b/nox/__init__.py
@@ -15,7 +15,7 @@
 from __future__ import annotations
 
 from nox import project
-from nox.__main__ import main
+from nox._cli import main
 from nox._options import noxfile_options as options
 from nox._parametrize import Param as param
 from nox._parametrize import parametrize_decorator as parametrize
diff --git a/nox/__main__.py b/nox/__main__.py
index 96f5128..ac183d6 100644
--- a/nox/__main__.py
+++ b/nox/__main__.py
@@ -19,56 +19,11 @@ function). This module primarily loads configuration, and then passes
 control to :meth:``nox.workflow.execute``.
 """
 
-from __future__ import annotations
+from __future__ import annotations  # pragma: no cover
 
-import sys
-from typing import Any
+from nox._cli import main  # pragma: no cover
 
-from nox import _options, tasks, workflow
-from nox._version import get_nox_version
-from nox.logger import setup_logging
-
-
-def execute_workflow(args: Any) -> int:
-    """
-    Execute the appropriate tasks.
-    """
-
-    return workflow.execute(
-        global_config=args,
-        workflow=(
-            tasks.load_nox_module,
-            tasks.merge_noxfile_options,
-            tasks.discover_manifest,
-            tasks.filter_manifest,
-            tasks.honor_list_request,
-            tasks.run_manifest,
-            tasks.print_summary,
-            tasks.create_report,
-            tasks.final_reduce,
-        ),
-    )
-
-
-def main() -> None:
-    args = _options.options.parse_args()
-
-    if args.help:
-        _options.options.print_help()
-        return
-
-    if args.version:
-        print(get_nox_version(), file=sys.stderr)
-        return
-
-    setup_logging(
-        color=args.color, verbose=args.verbose, add_timestamp=args.add_timestamp
-    )
-
-    exit_code = execute_workflow(args)
-
-    # Done; exit.
-    sys.exit(exit_code)
+__all__ = ["main"]  # pragma: no cover
 
 
 if __name__ == "__main__":  # pragma: no cover
diff --git a/nox/_cli.py b/nox/_cli.py
new file mode 100644
index 0000000..f25dad2
--- /dev/null
+++ b/nox/_cli.py
@@ -0,0 +1,66 @@
+# Copyright 2016 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""The Nox `main` function and helpers."""
+
+from __future__ import annotations
+
+import sys
+from typing import Any
+
+from nox import _options, tasks, workflow
+from nox._version import get_nox_version
+from nox.logger import setup_logging
+
+
+def execute_workflow(args: Any) -> int:
+    """
+    Execute the appropriate tasks.
+    """
+
+    return workflow.execute(
+        global_config=args,
+        workflow=(
+            tasks.load_nox_module,
+            tasks.merge_noxfile_options,
+            tasks.discover_manifest,
+            tasks.filter_manifest,
+            tasks.honor_list_request,
+            tasks.run_manifest,
+            tasks.print_summary,
+            tasks.create_report,
+            tasks.final_reduce,
+        ),
+    )
+
+
+def main() -> None:
+    args = _options.options.parse_args()
+
+    if args.help:
+        _options.options.print_help()
+        return
+
+    if args.version:
+        print(get_nox_version(), file=sys.stderr)
+        return
+
+    setup_logging(
+        color=args.color, verbose=args.verbose, add_timestamp=args.add_timestamp
+    )
+
+    exit_code = execute_workflow(args)
+
+    # Done; exit.
+    sys.exit(exit_code)
diff --git a/nox/_decorators.py b/nox/_decorators.py
index f7e3174..62db145 100644
--- a/nox/_decorators.py
+++ b/nox/_decorators.py
@@ -66,7 +66,7 @@ class Func(FunctionDecorator):
         reuse_venv: bool | None = None,
         name: str | None = None,
         venv_backend: Any = None,
-        venv_params: Any = None,
+        venv_params: Sequence[str] = (),
         should_warn: Mapping[str, Any] | None = None,
         tags: Sequence[str] | None = None,
         *,
diff --git a/nox/registry.py b/nox/registry.py
index d107a3f..db3db9f 100644
--- a/nox/registry.py
+++ b/nox/registry.py
@@ -40,7 +40,7 @@ def session_decorator(
     reuse_venv: bool | None = ...,
     name: str | None = ...,
     venv_backend: Any | None = ...,
-    venv_params: Any | None = ...,
+    venv_params: Sequence[str] = ...,
     tags: Sequence[str] | None = ...,
     *,
     default: bool = ...,
@@ -55,7 +55,7 @@ def session_decorator(
     reuse_venv: bool | None = None,
     name: str | None = None,
     venv_backend: Any | None = None,
-    venv_params: Any | None = None,
+    venv_params: Sequence[str] = (),
     tags: Sequence[str] | None = None,
     *,
     default: bool = True,
diff --git a/nox/sessions.py b/nox/sessions.py
index 5eb2b93..f7819c5 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -45,7 +45,13 @@ import nox.virtualenv
 from nox._decorators import Func
 from nox.logger import logger
 from nox.popen import DEFAULT_INTERRUPT_TIMEOUT, DEFAULT_TERMINATE_TIMEOUT
-from nox.virtualenv import CondaEnv, PassthroughEnv, ProcessEnv, VirtualEnv
+from nox.virtualenv import (
+    CondaEnv,
+    PassthroughEnv,
+    ProcessEnv,
+    VirtualEnv,
+    get_virtualenv,
+)
 
 if TYPE_CHECKING:
     from typing import IO
@@ -991,34 +997,13 @@ class SessionRunner:
             or "virtualenv"
         ).split("|")
 
-        # Support fallback backends
-        for bk in backends:
-            if bk not in nox.virtualenv.ALL_VENVS:
-                msg = f"Expected venv_backend one of {list(nox.virtualenv.ALL_VENVS)!r}, but got {bk!r}."
-                raise ValueError(msg)
-
-        for bk in backends[:-1]:
-            if bk not in nox.virtualenv.OPTIONAL_VENVS:
-                msg = f"Only optional backends ({list(nox.virtualenv.OPTIONAL_VENVS)!r}) may have a fallback, {bk!r} is not optional."
-                raise ValueError(msg)
-
-        for bk in backends:
-            if nox.virtualenv.OPTIONAL_VENVS.get(bk, True):
-                backend = bk
-                break
-        else:
-            msg = f"No backends present, looked for {backends!r}."
-            raise ValueError(msg)
-
-        if backend == "none" or self.func.python is False:
-            self.venv = nox.virtualenv.ALL_VENVS["none"]()
-        else:
-            self.venv = nox.virtualenv.ALL_VENVS[backend](
-                self.envdir,
-                interpreter=self.func.python,
-                reuse_existing=reuse_existing,
-                venv_params=self.func.venv_params,
-            )
+        self.venv = get_virtualenv(
+            *backends,
+            reuse_existing=reuse_existing,
+            envdir=self.envdir,
+            interpreter=self.func.python,
+            venv_params=self.func.venv_params,
+        )
 
         self.venv.create()
 
diff --git a/nox/tasks.py b/nox/tasks.py
index e257641..62bee9b 100644
--- a/nox/tasks.py
+++ b/nox/tasks.py
@@ -83,13 +83,15 @@ def load_nox_module(global_config: Namespace) -> types.ModuleType | int:
     # Be sure to expand variables
     global_config_noxfile = os.path.expandvars(global_config.noxfile)
 
+    # Make sure we only expand the parent dir just in case the noxfile is a symlink
+    noxfile_parent_dir = os.path.realpath(os.path.dirname(global_config_noxfile))
+
     # Save the absolute path to the Noxfile.
     # This will inoculate it if Nox changes paths because of an implicit
     # or explicit chdir (like the one below).
-    global_config.noxfile = os.path.realpath(global_config_noxfile)
-
-    # Make sure we only expand the parent dir just in case the noxfile is a symlink
-    noxfile_parent_dir = os.path.realpath(os.path.dirname(global_config.noxfile))
+    global_config.noxfile = os.path.join(
+        noxfile_parent_dir, os.path.basename(global_config_noxfile)
+    )
 
     try:
         # Check ``nox.needs_version`` by parsing the AST.
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index 514b2f3..8b9525c 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -24,7 +24,7 @@ import re
 import shutil
 import subprocess
 import sys
-from collections.abc import Callable, Mapping
+from collections.abc import Callable, Mapping, Sequence
 from pathlib import Path
 from socket import gethostbyname
 from typing import Any, ClassVar
@@ -33,6 +33,7 @@ from packaging import version
 
 import nox
 import nox.command
+from nox._typing import Python
 from nox.logger import logger
 
 # Problematic environment variables that are stripped from all commands inside
@@ -286,7 +287,7 @@ class CondaEnv(ProcessEnv):
         location: str,
         interpreter: str | None = None,
         reuse_existing: bool = False,
-        venv_params: Any = None,
+        venv_params: Sequence[str] = (),
         *,
         conda_cmd: str = "conda",
     ):
@@ -422,7 +423,7 @@ class VirtualEnv(ProcessEnv):
         reuse_existing: bool = False,
         *,
         venv_backend: str = "virtualenv",
-        venv_params: Any = None,
+        venv_params: Sequence[str] = (),
     ):
         self.location_name = location
         self.location = os.path.abspath(location)
@@ -662,3 +663,40 @@ OPTIONAL_VENVS = {
     "micromamba": shutil.which("micromamba") is not None,
     "uv": HAS_UV,
 }
+
+
+def get_virtualenv(
+    *backends: str,
+    envdir: str,
+    reuse_existing: bool,
+    interpreter: Python = None,
+    venv_params: Sequence[str] = (),
+) -> ProcessEnv:
+    # Support fallback backends
+    for bk in backends:
+        if bk not in ALL_VENVS:
+            msg = f"Expected venv_backend one of {sorted(ALL_VENVS)!r}, but got {bk!r}."
+            raise ValueError(msg)
+
+    for bk in backends[:-1]:
+        if bk not in OPTIONAL_VENVS:
+            msg = f"Only optional backends ({sorted(OPTIONAL_VENVS)!r}) may have a fallback, {bk!r} is not optional."
+            raise ValueError(msg)
+
+    for bk in backends:
+        if OPTIONAL_VENVS.get(bk, True):
+            backend = bk
+            break
+    else:
+        msg = f"No backends present, looked for {backends!r}."
+        raise ValueError(msg)
+
+    if backend == "none" or interpreter is False:
+        return ALL_VENVS["none"]()
+
+    return ALL_VENVS[backend](
+        envdir,
+        interpreter=interpreter,
+        reuse_existing=reuse_existing,
+        venv_params=venv_params,
+    )


Test patch:
diff --git a/tests/resources/orig_dir/noxfile.py b/tests/resources/orig_dir/noxfile.py
new file mode 100644
index 0000000..f6eb8ab
--- /dev/null
+++ b/tests/resources/orig_dir/noxfile.py
@@ -0,0 +1,17 @@
+from pathlib import Path
+
+import nox
+
+FILE = Path(__file__).resolve()
+
+
+@nox.session(venv_backend="none", default=False)
+def orig(session: nox.Session) -> None:
+    assert Path("orig_file.txt").exists()
+
+
+@nox.session(venv_backend="none", default=False)
+def sym(session: nox.Session) -> None:
+    assert Path("sym_file.txt").exists()
+
+    assert FILE.parent.joinpath("orig_file.txt").exists()
diff --git a/tests/resources/orig_dir/orig_file.txt b/tests/resources/orig_dir/orig_file.txt
new file mode 100644
index 0000000..e69de29
diff --git a/tests/resources/sym_dir/noxfile.py b/tests/resources/sym_dir/noxfile.py
new file mode 120000
index 0000000..67bd5e2
--- /dev/null
+++ b/tests/resources/sym_dir/noxfile.py
@@ -0,0 +1,1 @@
+../orig_dir/noxfile.py
\ No newline at end of file
diff --git a/tests/resources/sym_dir/sym_file.txt b/tests/resources/sym_dir/sym_file.txt
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_main.py b/tests/test_main.py
index 0cb737a..72bbe75 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -16,6 +16,7 @@ from __future__ import annotations
 
 import contextlib
 import os
+import subprocess
 import sys
 from importlib import metadata
 from pathlib import Path
@@ -928,3 +929,25 @@ def test_noxfile_options_cant_be_set():
 def test_noxfile_options_cant_be_set_long():
     with pytest.raises(AttributeError, match="i_am_clearly_not_an_option"):
         nox.options.i_am_clearly_not_an_option = True
+
+
+def test_symlink_orig(monkeypatch):
+    monkeypatch.chdir(Path(RESOURCES) / "orig_dir")
+    subprocess.run([sys.executable, "-m", "nox", "-s", "orig"], check=True)
+
+
+def test_symlink_orig_not(monkeypatch):
+    monkeypatch.chdir(Path(RESOURCES) / "orig_dir")
+    res = subprocess.run([sys.executable, "-m", "nox", "-s", "sym"], check=False)
+    assert res.returncode == 1
+
+
+def test_symlink_sym(monkeypatch):
+    monkeypatch.chdir(Path(RESOURCES) / "sym_dir")
+    subprocess.run([sys.executable, "-m", "nox", "-s", "sym"], check=True)
+
+
+def test_symlink_sym_not(monkeypatch):
+    monkeypatch.chdir(Path(RESOURCES) / "sym_dir")
+    res = subprocess.run([sys.executable, "-m", "nox", "-s", "orig"], check=False)
+    assert res.returncode == 1
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index b55d3bc..ce0b568 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -794,7 +794,7 @@ class TestSession:
             if "-x" in session.posargs:
                 raise RuntimeError("invalid option: -x")
 
-        config = _options.options.namespace(posargs=[])
+        config = _options.options.namespace(posargs=[], envdir=".nox")
         manifest = nox.manifest.Manifest(registry, config)
 
         assert manifest["test"].execute()