diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d9d7b42..481f6aca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,27 @@ jobs: python -m pip install --disable-pip-version-check . - name: Run tests on ${{ matrix.os }} run: nox --non-interactive --session "tests-${{ matrix.python-version }}" -- --full-trace + + build-py310: + name: Build python 3.10 + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-20.04, windows-2019] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: "3.10.0-rc.2" + # Conda does not support 3.10 yet, hence why it's skipped here + # TODO: Merge the two build jobs when 3.10 is released for conda + - name: Install Nox-under-test + run: | + python -m pip install --disable-pip-version-check . + - name: Run tests on ${{ matrix.os }} + run: nox --non-interactive --session "tests-3.10" -- --full-trace + lint: runs-on: ubuntu-20.04 steps: @@ -55,14 +76,8 @@ jobs: if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') steps: - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2 - with: - python-version: 3.9 - - name: Install setuptools and wheel - run: python -m pip install --upgrade --user setuptools wheel - name: Build sdist and wheel - run: python setup.py sdist bdist_wheel + run: pipx run build - name: Publish distribution PyPI uses: pypa/gh-action-pypi-publish@master with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e6c9d50..8d66f263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2021.6.12 + +- Fix crash on Python 2 when reusing environments. (#450) +- Hide staleness check behind a feature flag. (#451) +- Group command-line options in `--help` message by function. (#442) +- Avoid polluting tests with a .nox directory. (#445) + ## 2021.6.6 - Add option `--no-install` to skip install commands in reused environments. (#432) diff --git a/docs/index.rst b/docs/index.rst index 0071cbf6..59e51542 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -51,17 +51,23 @@ Projects that use Nox Nox is lucky to have several wonderful projects that use it and provide feedback and contributions. - `Bézier `__ +- `cibuildwheel `__ - `gapic-generator-python `__ - `gdbgui `__ - `Google Assistant SDK `__ - `google-cloud-python `__ - `google-resumable-media-python `__ - `Hydra `__ +- `manylinux `__ - `OmegaConf `__ - `OpenCensus Python `__ -- `packaging.python.org `__ -- `pipx `__ +- `packaging `__ +- `packaging.python.org `__ +- `pip `__ +- `pipx `__ - `Salt `__ +- `Scikit-build `__ +- `Scikit-HEP `__ - `Subpar `__ - `Urllib3 `__ - `Zazo `__ diff --git a/nox/_decorators.py b/nox/_decorators.py index 153e331b..cedd1fb9 100644 --- a/nox/_decorators.py +++ b/nox/_decorators.py @@ -18,7 +18,7 @@ def __new__( return cast("FunctionDecorator", functools.wraps(func)(obj)) -def _copy_func(src: Callable, name: str = None) -> Callable: +def _copy_func(src: Callable, name: Optional[str] = None) -> Callable: dst = types.FunctionType( src.__code__, src.__globals__, # type: ignore @@ -41,7 +41,7 @@ def __init__( name: Optional[str] = None, venv_backend: Any = None, venv_params: Any = None, - should_warn: Dict[str, Any] = None, + should_warn: Optional[Dict[str, Any]] = None, ): self.func = func self.python = python @@ -53,7 +53,7 @@ def __init__( def __call__(self, *args: Any, **kwargs: Any) -> Any: return self.func(*args, **kwargs) - def copy(self, name: str = None) -> "Func": + def copy(self, name: Optional[str] = None) -> "Func": return Func( _copy_func(self.func, name), self.python, @@ -68,7 +68,7 @@ def copy(self, name: str = None) -> "Func": class Call(Func): def __init__(self, func: Func, param_spec: "Param") -> None: call_spec = param_spec.call_spec - session_signature = "({})".format(param_spec) + session_signature = f"({param_spec})" # Determine the Python interpreter for the session using either @session # or @parametrize. For backwards compatibility, we only use a "python" diff --git a/nox/_option_set.py b/nox/_option_set.py index 4bcbb357..91dcc153 100644 --- a/nox/_option_set.py +++ b/nox/_option_set.py @@ -20,7 +20,8 @@ import argparse import collections import functools -from argparse import ArgumentError, ArgumentParser, Namespace +from argparse import ArgumentError as ArgumentError +from argparse import ArgumentParser, Namespace from typing import Any, Callable, List, Optional, Tuple, Union import argcomplete @@ -76,7 +77,7 @@ def __init__( self, name: str, *flags: str, - group: OptionGroup, + group: Optional[OptionGroup], help: Optional[str] = None, noxfile: bool = False, merge_func: Optional[Callable[[Namespace, Namespace], Any]] = None, @@ -84,7 +85,7 @@ def __init__( default: Union[Any, Callable[[], Any]] = None, hidden: bool = False, completer: Optional[Callable[..., List[str]]] = None, - **kwargs: Any + **kwargs: Any, ) -> None: self.name = name self.flags = flags @@ -155,14 +156,14 @@ def make_flag_pair( name: str, enable_flags: Union[Tuple[str, str], Tuple[str]], disable_flags: Tuple[str], - **kwargs: Any + **kwargs: Any, ) -> Tuple[Option, Option]: """Returns two options - one to enable a behavior and another to disable it. The positive option is considered to be available to the noxfile, as there isn't much point in doing flag pairs without it. """ - disable_name = "no_{}".format(name) + disable_name = f"no_{name}" kwargs["action"] = "store_true" enable_option = Option( @@ -170,12 +171,10 @@ def make_flag_pair( *enable_flags, noxfile=True, merge_func=functools.partial(flag_pair_merge_func, name, disable_name), - **kwargs + **kwargs, ) - kwargs["help"] = "Disables {} if it is enabled in the Noxfile.".format( - enable_flags[-1] - ) + kwargs["help"] = f"Disables {enable_flags[-1]} if it is enabled in the Noxfile." disable_option = Option(disable_name, *disable_flags, **kwargs) return enable_option, disable_option @@ -231,9 +230,15 @@ def parser(self) -> ArgumentParser: } for option in self.options.values(): - if option.hidden: + if option.hidden is True: continue + # Every option must have a group (except for hidden options) + if option.group is None: + raise ValueError( + f"Option {option.name} must either have a group or be hidden." + ) + argument = groups[option.group.name].add_argument( *option.flags, help=option.help, default=option.default, **option.kwargs ) @@ -284,7 +289,7 @@ def namespace(self, **kwargs: Any) -> argparse.Namespace: # used in tests. for key, value in kwargs.items(): if key not in args: - raise KeyError("{} is not an option.".format(key)) + raise KeyError(f"{key} is not an option.") args[key] = value return argparse.Namespace(**args) diff --git a/nox/_options.py b/nox/_options.py index 770b8438..353ed647 100644 --- a/nox/_options.py +++ b/nox/_options.py @@ -29,14 +29,34 @@ options.add_groups( _option_set.OptionGroup( - "primary", - "Primary arguments", - "These are the most common arguments used when invoking Nox.", + "general", + "General options", + "These are general arguments used when invoking Nox.", ), _option_set.OptionGroup( - "secondary", - "Additional arguments & flags", - "These arguments are used to control Nox's behavior or control advanced features.", + "sessions", + "Sessions options", + "These arguments are used to control which Nox session(s) to execute.", + ), + _option_set.OptionGroup( + "python", + "Python options", + "These arguments are used to control which Python version(s) to use.", + ), + _option_set.OptionGroup( + "environment", + "Environment options", + "These arguments are used to control Nox's creation and usage of virtual environments.", + ), + _option_set.OptionGroup( + "execution", + "Execution options", + "These arguments are used to control execution of sessions.", + ), + _option_set.OptionGroup( + "reporting", + "Reporting options", + "These arguments are used to control Nox's reporting during execution.", ), ) @@ -177,14 +197,14 @@ def _posargs_finalizer( if "--" not in posargs: unexpected_posargs = posargs raise _option_set.ArgumentError( - None, "Unknown argument(s) '{}'.".format(" ".join(unexpected_posargs)) + None, f"Unknown argument(s) '{' '.join(unexpected_posargs)}'." ) dash_index = posargs.index("--") if dash_index != 0: unexpected_posargs = posargs[0:dash_index] raise _option_set.ArgumentError( - None, "Unknown argument(s) '{}'.".format(" ".join(unexpected_posargs)) + None, f"Unknown argument(s) '{' '.join(unexpected_posargs)}'." ) return posargs[dash_index + 1 :] @@ -209,14 +229,14 @@ def _session_completer( "help", "-h", "--help", - group=options.groups["primary"], + group=options.groups["general"], action="store_true", help="Show this help message and exit.", ), _option_set.Option( "version", "--version", - group=options.groups["primary"], + group=options.groups["general"], action="store_true", help="Show the Nox version and exit.", ), @@ -225,7 +245,7 @@ def _session_completer( "-l", "--list-sessions", "--list", - group=options.groups["primary"], + group=options.groups["sessions"], action="store_true", help="List all available sessions and exit.", ), @@ -235,7 +255,7 @@ def _session_completer( "-e", "--sessions", "--session", - group=options.groups["primary"], + group=options.groups["sessions"], noxfile=True, merge_func=functools.partial(_sessions_and_keywords_merge_func, "sessions"), nargs="*", @@ -248,7 +268,7 @@ def _session_completer( "-p", "--pythons", "--python", - group=options.groups["primary"], + group=options.groups["python"], noxfile=True, nargs="*", help="Only run sessions that use the given python interpreter versions.", @@ -257,7 +277,7 @@ def _session_completer( "keywords", "-k", "--keywords", - group=options.groups["primary"], + group=options.groups["sessions"], noxfile=True, merge_func=functools.partial(_sessions_and_keywords_merge_func, "keywords"), help="Only run sessions that match the given expression.", @@ -265,7 +285,7 @@ def _session_completer( _option_set.Option( "posargs", "posargs", - group=options.groups["primary"], + group=options.groups["general"], nargs=argparse.REMAINDER, help="Arguments following ``--`` that are passed through to the session(s).", finalizer_func=_posargs_finalizer, @@ -274,7 +294,7 @@ def _session_completer( "verbose", "-v", "--verbose", - group=options.groups["secondary"], + group=options.groups["reporting"], action="store_true", help="Logs the output of all commands run including commands marked silent.", noxfile=True, @@ -283,7 +303,7 @@ def _session_completer( "add_timestamp", "-ts", "--add-timestamp", - group=options.groups["secondary"], + group=options.groups["reporting"], action="store_true", help="Adds a timestamp to logged output.", noxfile=True, @@ -292,7 +312,7 @@ def _session_completer( "default_venv_backend", "-db", "--default-venv-backend", - group=options.groups["secondary"], + group=options.groups["environment"], noxfile=True, merge_func=_default_venv_backend_merge_func, help="Virtual environment backend to use by default for nox sessions, this is ``'virtualenv'`` by default but " @@ -303,7 +323,7 @@ def _session_completer( "force_venv_backend", "-fb", "--force-venv-backend", - group=options.groups["secondary"], + group=options.groups["environment"], noxfile=True, merge_func=_force_venv_backend_merge_func, help="Virtual environment backend to force-use for all nox sessions in this run, overriding any other venv " @@ -314,7 +334,7 @@ def _session_completer( _option_set.Option( "no_venv", "--no-venv", - group=options.groups["secondary"], + group=options.groups["environment"], default=False, action="store_true", help="Runs the selected sessions directly on the current interpreter, without creating a venv. This is an alias " @@ -324,14 +344,14 @@ def _session_completer( "reuse_existing_virtualenvs", ("-r", "--reuse-existing-virtualenvs"), ("--no-reuse-existing-virtualenvs",), - group=options.groups["secondary"], + group=options.groups["environment"], help="Re-use existing virtualenvs instead of recreating them.", ), _option_set.Option( "R", "-R", default=False, - group=options.groups["secondary"], + group=options.groups["environment"], action="store_true", help=( "Re-use existing virtualenvs and skip package re-installation." @@ -343,7 +363,7 @@ def _session_completer( "noxfile", "-f", "--noxfile", - group=options.groups["secondary"], + group=options.groups["general"], default="noxfile.py", help="Location of the Python file containing nox sessions.", ), @@ -352,14 +372,14 @@ def _session_completer( "--envdir", noxfile=True, merge_func=_envdir_merge_func, - group=options.groups["secondary"], + group=options.groups["environment"], help="Directory where nox will store virtualenvs, this is ``.nox`` by default.", ), _option_set.Option( "extra_pythons", "--extra-pythons", "--extra-python", - group=options.groups["secondary"], + group=options.groups["python"], nargs="*", help="Additionally, run sessions using the given python interpreter versions.", ), @@ -367,7 +387,7 @@ def _session_completer( "force_pythons", "--force-pythons", "--force-python", - group=options.groups["secondary"], + group=options.groups["python"], nargs="*", help=( "Run sessions with the given interpreters instead of those listed in the Noxfile." @@ -379,27 +399,27 @@ def _session_completer( "stop_on_first_error", ("-x", "--stop-on-first-error"), ("--no-stop-on-first-error",), - group=options.groups["secondary"], + group=options.groups["execution"], help="Stop after the first error.", ), *_option_set.make_flag_pair( "error_on_missing_interpreters", ("--error-on-missing-interpreters",), ("--no-error-on-missing-interpreters",), - group=options.groups["secondary"], + group=options.groups["execution"], help="Error instead of skipping sessions if an interpreter can not be located.", ), *_option_set.make_flag_pair( "error_on_external_run", ("--error-on-external-run",), ("--no-error-on-external-run",), - group=options.groups["secondary"], + group=options.groups["execution"], help="Error if run() is used to execute a program that isn't installed in a session's virtualenv.", ), _option_set.Option( "install_only", "--install-only", - group=options.groups["secondary"], + group=options.groups["execution"], action="store_true", help="Skip session.run invocations in the Noxfile.", ), @@ -407,7 +427,7 @@ def _session_completer( "no_install", "--no-install", default=False, - group=options.groups["secondary"], + group=options.groups["execution"], action="store_true", help=( "Skip invocations of session methods for installing packages" @@ -418,14 +438,14 @@ def _session_completer( _option_set.Option( "report", "--report", - group=options.groups["secondary"], + group=options.groups["reporting"], noxfile=True, help="Output a report of all sessions to the given filename.", ), _option_set.Option( "non_interactive", "--non-interactive", - group=options.groups["secondary"], + group=options.groups["execution"], action="store_true", help="Force session.interactive to always be False, even in interactive sessions.", ), @@ -433,7 +453,7 @@ def _session_completer( "nocolor", "--nocolor", "--no-color", - group=options.groups["secondary"], + group=options.groups["reporting"], default=lambda: "NO_COLOR" in os.environ, action="store_true", help="Disable all color output.", @@ -442,7 +462,7 @@ def _session_completer( "forcecolor", "--forcecolor", "--force-color", - group=options.groups["secondary"], + group=options.groups["reporting"], default=False, action="store_true", help="Force color output, even if stdout is not an interactive terminal.", @@ -450,10 +470,18 @@ def _session_completer( _option_set.Option( "color", "--color", - group=options.groups["secondary"], + group=options.groups["reporting"], hidden=True, finalizer_func=_color_finalizer, ), + # Stores the original working directory that Nox was invoked from, + # since it could be different from the Noxfile's directory. + _option_set.Option( + "invoked_from", + group=None, + hidden=True, + default=lambda: os.getcwd(), + ), ) diff --git a/nox/_parametrize.py b/nox/_parametrize.py index be226e46..82022402 100644 --- a/nox/_parametrize.py +++ b/nox/_parametrize.py @@ -32,7 +32,7 @@ def __init__( self, *args: Any, arg_names: Optional[Sequence[str]] = None, - id: Optional[str] = None + id: Optional[str] = None, ) -> None: self.args = tuple(args) self.id = id @@ -51,7 +51,7 @@ def __str__(self) -> str: return self.id else: call_spec = self.call_spec - args = ["{}={}".format(k, repr(call_spec[k])) for k in call_spec.keys()] + args = [f"{k}={call_spec[k]!r}" for k in call_spec.keys()] return ", ".join(args) __repr__ = __str__ diff --git a/nox/_version.py b/nox/_version.py index 740b7fd3..2e247534 100644 --- a/nox/_version.py +++ b/nox/_version.py @@ -36,7 +36,7 @@ class InvalidVersionSpecifier(Exception): def get_nox_version() -> str: """Return the version of the installed Nox package.""" - return metadata.version("nox") + return metadata.version("nox") # type: ignore def _parse_string_constant(node: ast.AST) -> Optional[str]: # pragma: no cover diff --git a/nox/command.py b/nox/command.py index 8d915054..5f215096 100644 --- a/nox/command.py +++ b/nox/command.py @@ -21,11 +21,16 @@ from nox.logger import logger from nox.popen import popen +if sys.version_info < (3, 8): # pragma: no cover + from typing_extensions import Literal +else: # pragma: no cover + from typing import Literal + class CommandFailed(Exception): """Raised when an executed command returns a non-success status code.""" - def __init__(self, reason: str = None) -> None: + def __init__(self, reason: Optional[str] = None) -> None: super(CommandFailed, self).__init__(reason) self.reason = reason @@ -45,8 +50,8 @@ def which(program: str, paths: Optional[List[str]]) -> str: if full_path: return full_path.strpath - logger.error("Program {} not found.".format(program)) - raise CommandFailed("Program {} not found".format(program)) + logger.error(f"Program {program} not found.") + raise CommandFailed(f"Program {program} not found") def _clean_env(env: Optional[dict]) -> Optional[dict]: @@ -70,8 +75,8 @@ def run( paths: Optional[List[str]] = None, success_codes: Optional[Iterable[int]] = None, log: bool = True, - external: bool = False, - **popen_kws: Any + external: Union[Literal["error"], bool] = False, + **popen_kws: Any, ) -> Union[str, bool]: """Run a command-line program.""" @@ -79,7 +84,7 @@ def run( success_codes = [0] cmd, args = args[0], args[1:] - full_cmd = "{} {}".format(cmd, " ".join(args)) + full_cmd = f"{cmd} {' '.join(args)}" cmd_path = which(cmd, paths) @@ -92,18 +97,14 @@ def run( if is_external_tool: if external == "error": logger.error( - "Error: {} is not installed into the virtualenv, it is located at {}. " - "Pass external=True into run() to explicitly allow this.".format( - cmd, cmd_path - ) + f"Error: {cmd} is not installed into the virtualenv, it is located at {cmd_path}. " + "Pass external=True into run() to explicitly allow this." ) raise CommandFailed("External program disallowed.") elif external is False: logger.warning( - "Warning: {} is not installed into the virtualenv, it is located at {}. This might cause issues! " - "Pass external=True into run() to silence this message.".format( - cmd, cmd_path - ) + f"Warning: {cmd} is not installed into the virtualenv, it is located at {cmd_path}. This might cause issues! " + "Pass external=True into run() to silence this message." ) env = _clean_env(env) @@ -114,16 +115,15 @@ def run( ) if return_code not in success_codes: + suffix = ":" if silent else "" logger.error( - "Command {} failed with exit code {}{}".format( - full_cmd, return_code, ":" if silent else "" - ) + f"Command {full_cmd} failed with exit code {return_code}{suffix}" ) if silent: sys.stderr.write(output) - raise CommandFailed("Returned code {}".format(return_code)) + raise CommandFailed(f"Returned code {return_code}") if output: logger.output(output) diff --git a/nox/manifest.py b/nox/manifest.py index dbc3304e..72c3d05b 100644 --- a/nox/manifest.py +++ b/nox/manifest.py @@ -55,15 +55,21 @@ class Manifest: session_functions (Mapping[str, function]): The registry of discovered session functions. global_config (.nox.main.GlobalConfig): The global configuration. + module_docstring (Optional[str]): The user noxfile.py docstring. + Defaults to `None`. """ def __init__( - self, session_functions: Mapping[str, "Func"], global_config: argparse.Namespace + self, + session_functions: Mapping[str, "Func"], + global_config: argparse.Namespace, + module_docstring: Optional[str] = None, ) -> None: self._all_sessions = [] # type: List[SessionRunner] self._queue = [] # type: List[SessionRunner] self._consumed = [] # type: List[SessionRunner] self._config = global_config # type: argparse.Namespace + self.module_docstring = module_docstring # type: Optional[str] # Create the sessions based on the provided session functions. for name, func in session_functions.items(): @@ -156,7 +162,7 @@ def filter_by_name(self, specified_sessions: Iterable[str]) -> None: if _normalize_arg(session_name) not in all_sessions ] if missing_sessions: - raise KeyError("Sessions not found: {}".format(", ".join(missing_sessions))) + raise KeyError(f"Sessions not found: {', '.join(missing_sessions)}") def filter_by_python_interpreter(self, specified_pythons: Sequence[str]) -> None: """Filter sessions in the queue based on the user-specified @@ -241,7 +247,7 @@ def make_session( if not multi: long_names.append(name) if func.python: - long_names.append("{}-{}".format(name, func.python)) + long_names.append(f"{name}-{func.python}") return [SessionRunner(name, long_names, func, self._config, self)] @@ -252,13 +258,11 @@ def make_session( for call in calls: long_names = [] if not multi: - long_names.append("{}{}".format(name, call.session_signature)) + long_names.append(f"{name}{call.session_signature}") if func.python: - long_names.append( - "{}-{}{}".format(name, func.python, call.session_signature) - ) + long_names.append(f"{name}-{func.python}{call.session_signature}") # Ensure that specifying session-python will run all parameterizations. - long_names.append("{}-{}".format(name, func.python)) + long_names.append(f"{name}-{func.python}") sessions.append(SessionRunner(name, long_names, call, self._config, self)) @@ -312,7 +316,7 @@ def notify( return True # The session was not found in the list of sessions. - raise ValueError("Session {} not found.".format(session)) + raise ValueError(f"Session {session} not found.") class KeywordLocals(collections.abc.Mapping): diff --git a/nox/popen.py b/nox/popen.py index 9bd1c115..010dd321 100644 --- a/nox/popen.py +++ b/nox/popen.py @@ -16,7 +16,7 @@ import locale import subprocess import sys -from typing import IO, Mapping, Sequence, Tuple, Union +from typing import IO, Mapping, Optional, Sequence, Tuple, Union def shutdown_process(proc: subprocess.Popen) -> Tuple[bytes, bytes]: @@ -54,9 +54,9 @@ def decode_output(output: bytes) -> str: def popen( args: Sequence[str], - env: Mapping[str, str] = None, + env: Optional[Mapping[str, str]] = None, silent: bool = False, - stdout: Union[int, IO] = None, + stdout: Optional[Union[int, IO]] = None, stderr: Union[int, IO] = subprocess.STDOUT, ) -> Tuple[int, str]: if silent and stdout is not None: diff --git a/nox/sessions.py b/nox/sessions.py index 145ee879..7627ddda 100644 --- a/nox/sessions.py +++ b/nox/sessions.py @@ -16,6 +16,7 @@ import enum import hashlib import os +import pathlib import re import sys import unicodedata @@ -63,9 +64,9 @@ def _normalize_path(envdir: str, path: Union[str, bytes]) -> str: logger.warning("The virtualenv name was hashed to avoid being too long.") else: logger.error( - "The virtualenv path {} is too long and will cause issues on " + f"The virtualenv path {full_path} is too long and will cause issues on " "some environments. Use the --envdir path to modify where " - "nox stores virtualenvs.".format(full_path) + "nox stores virtualenvs." ) return full_path @@ -79,7 +80,7 @@ def _dblquote_pkg_install_arg(pkg_req_str: str) -> str: # sanity check: we need an even number of double-quotes if pkg_req_str.count('"') % 2 != 0: raise ValueError( - "ill-formated argument with odd number of quotes: %s" % pkg_req_str + f"ill-formated argument with odd number of quotes: {pkg_req_str}" ) if "<" in pkg_req_str or ">" in pkg_req_str: @@ -89,10 +90,8 @@ def _dblquote_pkg_install_arg(pkg_req_str: str) -> str: else: # need to double-quote string if '"' in pkg_req_str: - raise ValueError( - "Cannot escape requirement string: %s" % pkg_req_str - ) - return '"%s"' % pkg_req_str + raise ValueError(f"Cannot escape requirement string: {pkg_req_str}") + return f'"{pkg_req_str}"' else: # no dangerous char: no need to double-quote string return pkg_req_str @@ -186,14 +185,33 @@ def create_tmp(self) -> str: self.env["TMPDIR"] = tmpdir return tmpdir + @property + def cache_dir(self) -> pathlib.Path: + """Create and return a 'shared cache' directory to be used across sessions.""" + path = pathlib.Path(self._runner.global_config.envdir).joinpath(".cache") + path.mkdir(exist_ok=True) + return path + @property def interactive(self) -> bool: """Returns True if Nox is being run in an interactive session or False otherwise.""" return not self._runner.global_config.non_interactive and sys.stdin.isatty() + @property + def invoked_from(self) -> str: + """The directory that Nox was originally invoked from. + + Since you can use the ``--noxfile / -f`` command-line + argument to run a Noxfile in a location different from your shell's + current working directory, Nox automatically changes the working directory + to the Noxfile's directory before running any sessions. This gives + you the original working directory that Nox was invoked form. + """ + return self._runner.global_config.invoked_from + def chdir(self, dir: Union[str, os.PathLike]) -> None: """Change the current working directory.""" - self.log("cd {}".format(dir)) + self.log(f"cd {dir}") os.chdir(dir) cd = chdir @@ -203,15 +221,15 @@ def _run_func( self, func: Callable, args: Iterable[Any], kwargs: Mapping[str, Any] ) -> Any: """Legacy support for running a function through :func`run`.""" - self.log("{}(args={!r}, kwargs={!r})".format(func, args, kwargs)) + self.log(f"{func}(args={args!r}, kwargs={kwargs!r})") try: return func(*args, **kwargs) except Exception as e: - logger.exception("Function {!r} raised {!r}.".format(func, e)) + logger.exception(f"Function {func!r} raised {e!r}.") raise nox.command.CommandFailed() def run( - self, *args: str, env: Mapping[str, str] = None, **kwargs: Any + self, *args: str, env: Optional[Mapping[str, str]] = None, **kwargs: Any ) -> Optional[Any]: """Run a command. @@ -263,13 +281,13 @@ def run( raise ValueError("At least one argument required to run().") if self._runner.global_config.install_only: - logger.info("Skipping {} run, as --install-only is set.".format(args[0])) + logger.info(f"Skipping {args[0]} run, as --install-only is set.") return None return self._run(*args, env=env, **kwargs) def run_always( - self, *args: str, env: Mapping[str, str] = None, **kwargs: Any + self, *args: str, env: Optional[Mapping[str, str]] = None, **kwargs: Any ) -> Optional[Any]: """Run a command **always**. @@ -311,7 +329,9 @@ def run_always( return self._run(*args, env=env, **kwargs) - def _run(self, *args: str, env: Mapping[str, str] = None, **kwargs: Any) -> Any: + def _run( + self, *args: str, env: Optional[Mapping[str, str]] = None, **kwargs: Any + ) -> Any: """Like run(), except that it runs even if --install-only is provided.""" # Legacy support - run a function given. if callable(args[0]): @@ -408,7 +428,7 @@ def conda_install( *prefix_args, *args, external="error", - **kwargs + **kwargs, ) def install(self, *args: str, **kwargs: Any) -> None: @@ -465,6 +485,20 @@ def notify( This method is idempotent; multiple notifications to the same session have no effect. + A common use case is to notify a code coverage analysis session + from a test session:: + + @nox.session + def test(session): + session.run("pytest") + session.notify("coverage") + + @nox.session + def coverage(session): + session.run("coverage") + + Now if you run `nox -s test`, the coverage session will run afterwards. + Args: target (Union[str, Callable]): The session to be notified. This may be specified as the appropriate string (same as used for @@ -518,7 +552,7 @@ def description(self) -> Optional[str]: def __str__(self) -> str: sigs = ", ".join(self.signatures) - return "Session(name={}, signatures={})".format(self.name, sigs) + return f"Session(name={self.name}, signatures={sigs})" @property def friendly_name(self) -> str: @@ -568,15 +602,13 @@ def _create_venv(self) -> None: ) else: raise ValueError( - "Expected venv_backend one of ('virtualenv', 'conda', 'venv'), but got '{}'.".format( - backend - ) + f"Expected venv_backend one of ('virtualenv', 'conda', 'venv'), but got '{backend}'." ) self.venv.create() def execute(self) -> "Result": - logger.warning("Running session {}".format(self.friendly_name)) + logger.warning(f"Running session {self.friendly_name}") try: # By default, nox should quietly change to the directory where @@ -609,13 +641,11 @@ def execute(self) -> "Result": return Result(self, Status.FAILED) except KeyboardInterrupt: - logger.error("Session {} interrupted.".format(self.friendly_name)) + logger.error(f"Session {self.friendly_name} interrupted.") raise except Exception as exc: - logger.exception( - "Session {} raised exception {!r}".format(self.friendly_name, exc) - ) + logger.exception(f"Session {self.friendly_name} raised exception {exc!r}") return Result(self, Status.FAILED) @@ -654,7 +684,7 @@ def imperfect(self) -> str: return "was successful" status = self.status.name.lower() if self.reason: - return "{}: {}".format(status, self.reason) + return f"{status}: {self.reason}" else: return status diff --git a/nox/tasks.py b/nox/tasks.py index c23b118d..52263321 100644 --- a/nox/tasks.py +++ b/nox/tasks.py @@ -52,6 +52,7 @@ def load_nox_module(global_config: Namespace) -> Union[types.ModuleType, int]: # Be sure to expand variables os.path.expandvars(global_config.noxfile) ) + noxfile_parent_dir = os.path.realpath(os.path.dirname(global_config.noxfile)) # Check ``nox.needs_version`` by parsing the AST. check_nox_version(global_config.noxfile) @@ -59,17 +60,23 @@ def load_nox_module(global_config: Namespace) -> Union[types.ModuleType, int]: # Move to the path where the Noxfile is. # This will ensure that the Noxfile's path is on sys.path, and that # import-time path resolutions work the way the Noxfile author would - # guess. - os.chdir(os.path.realpath(os.path.dirname(global_config.noxfile))) + # guess. The original working directory (the directory that Nox was + # invoked from) gets stored by the .invoke_from "option" in _options. + os.chdir(noxfile_parent_dir) return importlib.machinery.SourceFileLoader( "user_nox_module", global_config.noxfile - ).load_module() # type: ignore + ).load_module() except (VersionCheckFailed, InvalidVersionSpecifier) as error: logger.error(str(error)) return 2 + except FileNotFoundError: + logger.error( + f"Failed to load Noxfile {global_config.noxfile}, no such file exists." + ) + return 2 except (IOError, OSError): - logger.exception("Failed to load Noxfile {}".format(global_config.noxfile)) + logger.exception(f"Failed to load Noxfile {global_config.noxfile}") return 2 @@ -104,8 +111,11 @@ def discover_manifest( # sorted by decorator call time. functions = registry.get() + # Get the docstring from the noxfile + module_docstring = module.__doc__ + # Return the final dictionary of session functions. - return Manifest(functions, global_config) + return Manifest(functions, global_config, module_docstring) def filter_manifest( @@ -166,9 +176,11 @@ def honor_list_request( return manifest # If the user just asked for a list of sessions, print that - # and be done. + # and any docstring specified in noxfile.py and be done. + if manifest.module_docstring: + print(manifest.module_docstring.strip(), end="\n\n") - print("Sessions defined in {noxfile}:\n".format(noxfile=global_config.noxfile)) + print(f"Sessions defined in {global_config.noxfile}:\n") reset = parse_colors("reset") if global_config.color else "" selected_color = parse_colors("cyan") if global_config.color else "" @@ -198,9 +210,7 @@ def honor_list_request( ) print( - "\nsessions marked with {selected_color}*{reset} are selected, sessions marked with {skipped_color}-{reset} are skipped.".format( - selected_color=selected_color, skipped_color=skipped_color, reset=reset - ) + f"\nsessions marked with {selected_color}*{reset} are selected, sessions marked with {skipped_color}-{reset} are skipped." ) return 0 @@ -244,17 +254,14 @@ def run_manifest(manifest: Manifest, global_config: Namespace) -> List[Result]: # possibly raise warnings associated with this session if WARN_PYTHONS_IGNORED in session.func.should_warn: logger.warning( - "Session {} is set to run with venv_backend='none', IGNORING its python={} parametrization. ".format( - session.name, session.func.should_warn[WARN_PYTHONS_IGNORED] - ) + f"Session {session.name} is set to run with venv_backend='none', " + f"IGNORING its python={session.func.should_warn[WARN_PYTHONS_IGNORED]} parametrization. " ) result = session.execute() - result.log( - "Session {name} {status}.".format( - name=session.friendly_name, status=result.imperfect - ) - ) + name = session.friendly_name + status = result.imperfect + result.log(f"Session {name} {status}.") results.append(result) # Sanity check: If we are supposed to stop on the first error case, @@ -285,11 +292,9 @@ def print_summary(results: List[Result], global_config: Namespace) -> List[Resul # human-readable way. logger.warning("Ran multiple sessions:") for result in results: - result.log( - "* {name}: {status}".format( - name=result.session.friendly_name, status=result.status.name.lower() - ) - ) + name = result.session.friendly_name + status = result.status.name.lower() + result.log(f"* {name}: {status}") # Return the results that were sent to this function. return results diff --git a/nox/tox_to_nox.py b/nox/tox_to_nox.py index 222f5fb9..7e121901 100644 --- a/nox/tox_to_nox.py +++ b/nox/tox_to_nox.py @@ -29,7 +29,7 @@ def wrapjoin(seq: Iterator[Any]) -> str: - return ", ".join(["'{}'".format(item) for item in seq]) + return ", ".join([f"'{item}'" for item in seq]) def main() -> None: diff --git a/nox/virtualenv.py b/nox/virtualenv.py index 71dc54fb..69f1ab81 100644 --- a/nox/virtualenv.py +++ b/nox/virtualenv.py @@ -22,6 +22,7 @@ import py +import nox import nox.command from nox.logger import logger @@ -33,11 +34,12 @@ ["PIP_RESPECT_VIRTUALENV", "PIP_REQUIRE_VIRTUALENV", "__PYVENV_LAUNCHER__"] ) _SYSTEM = platform.system() +_ENABLE_STALENESS_CHECK = "NOX_ENABLE_STALENESS_CHECK" in os.environ class InterpreterNotFound(OSError): def __init__(self, interpreter: str) -> None: - super().__init__("Python interpreter {} not found".format(interpreter)) + super().__init__(f"Python interpreter {interpreter} not found") self.interpreter = interpreter @@ -52,7 +54,9 @@ class ProcessEnv: # Special programs that aren't included in the environment. allowed_globals = () # type: _typing.ClassVar[Tuple[Any, ...]] - def __init__(self, bin_paths: None = None, env: Mapping[str, str] = None) -> None: + def __init__( + self, bin_paths: None = None, env: Optional[Mapping[str, str]] = None + ) -> None: self._bin_paths = bin_paths self.env = os.environ.copy() self._reused = False @@ -135,7 +139,7 @@ def locate_using_path_and_version(version: str) -> Optional[str]: path_python = py.path.local.sysfind("python") if path_python: try: - prefix = "{}.".format(version) + prefix = f"{version}." version_string = path_python.sysexec("-c", script).strip() if version_string.startswith(prefix): return str(path_python) @@ -238,9 +242,7 @@ def bin_paths(self) -> List[str]: def create(self) -> bool: """Create the conda env.""" if not self._clean_location(): - logger.debug( - "Re-using existing conda env at {}.".format(self.location_name) - ) + logger.debug(f"Re-using existing conda env at {self.location_name}.") self._reused = True @@ -254,15 +256,13 @@ def create(self) -> bool: cmd.append("pip") if self.interpreter: - python_dep = "python={}".format(self.interpreter) + python_dep = f"python={self.interpreter}" else: python_dep = "python" cmd.append(python_dep) - logger.info( - "Creating conda env in {} with {}".format(self.location_name, python_dep) - ) - nox.command.run(cmd, silent=True, log=False) + logger.info(f"Creating conda env in {self.location_name} with {python_dep}") + nox.command.run(cmd, silent=True, log=nox.options.verbose or False) return True @@ -327,6 +327,8 @@ def __init__( def _clean_location(self) -> bool: """Deletes any existing virtual environment""" if os.path.exists(self.location): + if self.reuse_existing and not _ENABLE_STALENESS_CHECK: + return False if ( self.reuse_existing and self._check_reused_environment_type() @@ -354,15 +356,35 @@ def _check_reused_environment_type(self) -> bool: def _check_reused_environment_interpreter(self) -> bool: """Check if reused environment interpreter is the same.""" - program = "import sys; print(getattr(sys, 'real_prefix', sys.base_prefix))" - original = nox.command.run( - [self._resolved_interpreter, "-c", program], silent=True, log=False + original = self._read_base_prefix_from_pyvenv_cfg() + program = ( + "import sys; sys.stdout.write(getattr(sys, 'real_prefix', sys.base_prefix))" ) + + if original is None: + output = nox.command.run( + [self._resolved_interpreter, "-c", program], silent=True, log=False + ) + assert isinstance(output, str) + original = output + created = nox.command.run( ["python", "-c", program], silent=True, log=False, paths=self.bin_paths ) + return original == created + def _read_base_prefix_from_pyvenv_cfg(self) -> Optional[str]: + """Return the base-prefix entry from pyvenv.cfg, if present.""" + path = os.path.join(self.location, "pyvenv.cfg") + if os.path.isfile(path): + with open(path) as io: + for line in io: + key, _, value = line.partition("=") + if key.strip() == "base-prefix": + return value.strip() + return None + @property def _resolved_interpreter(self) -> str: """Return the interpreter, appropriately resolved for the platform. @@ -392,7 +414,7 @@ def _resolved_interpreter(self) -> str: match = re.match(r"^(?P\d(\.\d+)?)(\.\d+)?$", self.interpreter) if match: xy_version = match.group("xy_ver") - cleaned_interpreter = "python{}".format(xy_version) + cleaned_interpreter = f"python{xy_version}" # If the cleaned interpreter is on the PATH, go ahead and return it. if py.path.local.sysfind(cleaned_interpreter): @@ -439,9 +461,7 @@ def create(self) -> bool: """Create the virtualenv or venv.""" if not self._clean_location(): logger.debug( - "Re-using existing virtual environment at {}.".format( - self.location_name - ) + f"Re-using existing virtual environment at {self.location_name}." ) self._reused = True @@ -456,13 +476,11 @@ def create(self) -> bool: cmd = [self._resolved_interpreter, "-m", "venv", self.location] cmd.extend(self.venv_params) + resolved_interpreter_name = os.path.basename(self._resolved_interpreter) + logger.info( - "Creating virtual environment ({}) using {} in {}".format( - self.venv_or_virtualenv, - os.path.basename(self._resolved_interpreter), - self.location_name, - ) + f"Creating virtual environment ({self.venv_or_virtualenv}) using {resolved_interpreter_name} in {self.location_name}" ) - nox.command.run(cmd, silent=True, log=False) + nox.command.run(cmd, silent=True, log=nox.options.verbose or False) return True diff --git a/noxfile.py b/noxfile.py index 9e247bb2..c269ee79 100644 --- a/noxfile.py +++ b/noxfile.py @@ -12,9 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. + import functools import os import platform +import sys import nox @@ -29,7 +31,7 @@ def is_python_version(session, version): return py_version.startswith(version) -@nox.session(python=["3.6", "3.7", "3.8", "3.9"]) +@nox.session(python=["3.6", "3.7", "3.8", "3.9", "3.10"]) def tests(session): """Run test suite with pytest.""" session.create_tmp() @@ -46,11 +48,12 @@ def tests(session): ".coveragerc", "--cov-report=", *tests, - env={"COVERAGE_FILE": ".coverage.{}".format(session.python)} + env={"COVERAGE_FILE": f".coverage.{session.python}"}, ) session.notify("cover") +# TODO: When conda supports 3.10 on GHA, add here too @nox.session(python=["3.6", "3.7", "3.8", "3.9"], venv_backend="conda") def conda_tests(session): """Run test suite with pytest.""" @@ -69,9 +72,16 @@ def cover(session): if ON_WINDOWS_CI: return + # 3.10 produces different coverage results for some reason + # see https://github.com/theacodes/nox/issues/478 + fail_under = 100 + py_version = sys.version_info + if py_version.major == 3 and py_version.minor == 10: + fail_under = 99 + session.install("coverage") session.run("coverage", "combine") - session.run("coverage", "report", "--fail-under=100", "--show-missing") + session.run("coverage", "report", f"--fail-under={fail_under}", "--show-missing") session.run("coverage", "erase") @@ -79,29 +89,30 @@ def cover(session): def blacken(session): """Run black code formatter.""" session.install("black==21.5b2", "isort==5.8.0") - files = ["nox", "tests", "noxfile.py", "setup.py"] + files = ["nox", "tests", "noxfile.py"] session.run("black", *files) session.run("isort", *files) @nox.session(python="3.8") def lint(session): - session.install("flake8==3.9.2", "black==21.5b2", "isort==5.8.0", "mypy==0.812") - session.run( - "mypy", - "--config-file=", - "--disallow-untyped-defs", - "--warn-unused-ignores", - "--ignore-missing-imports", - "nox", + session.install( + "flake8==3.9.2", + "black==21.6b0", + "isort==5.8.0", + "mypy==0.902", + "types-jinja2", + "packaging", + "importlib_metadata", ) - files = ["nox", "tests", "noxfile.py", "setup.py"] + session.run("mypy") + files = ["nox", "tests", "noxfile.py"] session.run("black", "--check", *files) session.run("isort", "--check", *files) session.run("flake8", *files) -@nox.session(python="3.7") +@nox.session(python="3.8") def docs(session): """Build the documentation.""" output_dir = os.path.join(session.create_tmp(), "output") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..90bcd11d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = [ + "setuptools>=42", + "wheel", +] +build-backend = "setuptools.build_meta" + + +[tool.mypy] +files = ["nox"] +python_version = "3.6" +warn_unused_configs = true +disallow_any_generics = false +disallow_subclassing_any = false +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = false +no_implicit_reexport = true +strict_equality = true + +[[tool.mypy.overrides]] +module = [ "argcomplete", "colorlog.*", "py", "tox.*" ] +ignore_missing_imports = true \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..5d720982 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,61 @@ +[metadata] +name = nox +version = 2021.6.12 +description = Flexible test automation. +long_description = file: README.rst +long_description_content_type = text/x-rst +url = https://nox.thea.codes +author = Alethea Katherine Flowers +author_email = me@thea.codes +license = Apache-2.0 +license_file = LICENSE +classifiers = + Development Status :: 5 - Production/Stable + Environment :: Console + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Operating System :: MacOS + Operating System :: Microsoft :: Windows + Operating System :: POSIX + Operating System :: Unix + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Topic :: Software Development :: Testing +keywords = testing automation tox +project_urls = + Documentation = https://nox.thea.codes + Source Code = https://github.com/theacodes/nox + Bug Tracker = https://github.com/theacodes/nox/issues + +[options] +packages = + nox +install_requires = + argcomplete>=1.9.4,<2.0 + colorlog>=2.6.1,<7.0.0 + packaging>=20.9 + py>=1.4.0,<2.0.0 + typing_extensions>=3.7.4;python_version < '3.8' + virtualenv>=14.0.0 + importlib_metadata;python_version < '3.8' +python_requires = >=3.6 +include_package_data = True +zip_safe = False + +[options.entry_points] +console_scripts = + nox = nox.__main__:main + tox-to-nox = nox.tox_to_nox:main [tox_to_nox] + +[options.extras_require] +tox_to_nox = + jinja2 + tox + +[options.package_data] +nox = py.typed diff --git a/setup.py b/setup.py deleted file mode 100644 index d3db6370..00000000 --- a/setup.py +++ /dev/null @@ -1,73 +0,0 @@ -# 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. - -from codecs import open - -from setuptools import setup - -long_description = open("README.rst", "r", encoding="utf-8").read() - -setup( - name="nox", - version="2021.6.6", - description="Flexible test automation.", - long_description=long_description, - url="https://nox.thea.codes", - author="Alethea Katherine Flowers", - author_email="me@thea.codes", - license="Apache Software License", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Topic :: Software Development :: Testing", - "Environment :: Console", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Operating System :: POSIX", - "Operating System :: MacOS", - "Operating System :: Unix", - "Operating System :: Microsoft :: Windows", - ], - keywords="testing automation tox", - packages=["nox"], - package_data={"nox": ["py.typed"]}, - include_package_data=True, - zip_safe=False, - install_requires=[ - "argcomplete>=1.9.4,<2.0", - "colorlog>=2.6.1,<7.0.0", - "packaging>=20.9", - "py>=1.4.0,<2.0.0", - "virtualenv>=14.0.0", - "importlib_metadata; python_version < '3.8'", - ], - extras_require={"tox_to_nox": ["jinja2", "tox"]}, - entry_points={ - "console_scripts": [ - "nox=nox.__main__:main", - "tox-to-nox=nox.tox_to_nox:main [tox_to_nox]", - ] - }, - project_urls={ - "Documentation": "https://nox.thea.codes", - "Source Code": "https://github.com/theacodes/nox", - "Bug Tracker": "https://github.com/theacodes/nox/issues", - }, - python_requires=">=3.6", -) diff --git a/tests/resources/noxfile_nested.py b/tests/resources/noxfile_nested.py index b7e70327..7a254acd 100644 --- a/tests/resources/noxfile_nested.py +++ b/tests/resources/noxfile_nested.py @@ -18,4 +18,4 @@ @nox.session(py=False) @nox.parametrize("cheese", ["cheddar", "jack", "brie"]) def snack(unused_session, cheese): - print("Noms, {} so good!".format(cheese)) + print(f"Noms, {cheese} so good!") diff --git a/tests/resources/noxfile_pythons.py b/tests/resources/noxfile_pythons.py index bfba5a08..a9a03d30 100644 --- a/tests/resources/noxfile_pythons.py +++ b/tests/resources/noxfile_pythons.py @@ -4,4 +4,4 @@ @nox.session(python=["3.6"]) @nox.parametrize("cheese", ["cheddar", "jack", "brie"]) def snack(unused_session, cheese): - print("Noms, {} so good!".format(cheese)) + print(f"Noms, {cheese} so good!") diff --git a/tests/resources/noxfile_spaces.py b/tests/resources/noxfile_spaces.py index 8c006f74..f11ca9a4 100644 --- a/tests/resources/noxfile_spaces.py +++ b/tests/resources/noxfile_spaces.py @@ -18,4 +18,4 @@ @nox.session(py=False, name="cheese list") @nox.parametrize("cheese", ["cheddar", "jack", "brie"]) def snack(unused_session, cheese): - print("Noms, {} so good!".format(cheese)) + print(f"Noms, {cheese} so good!") diff --git a/tests/test__option_set.py b/tests/test__option_set.py index 202473ea..f8f14211 100644 --- a/tests/test__option_set.py +++ b/tests/test__option_set.py @@ -55,6 +55,29 @@ def test_namespace_non_existant_options_with_values(self): with pytest.raises(KeyError): optionset.namespace(non_existant_option="meep") + def test_parser_hidden_option(self): + optionset = _option_set.OptionSet() + optionset.add_options( + _option_set.Option( + "oh_boy_i_am_hidden", hidden=True, group=None, default="meep" + ) + ) + + parser = optionset.parser() + namespace = parser.parse_args([]) + optionset._finalize_args(namespace) + + assert namespace.oh_boy_i_am_hidden == "meep" + + def test_parser_groupless_option(self): + optionset = _option_set.OptionSet() + optionset.add_options( + _option_set.Option("oh_no_i_have_no_group", group=None, default="meep") + ) + + with pytest.raises(ValueError): + optionset.parser() + def test_session_completer(self): parsed_args = _options.options.namespace(sessions=(), keywords=(), posargs=[]) all_nox_sessions = _options._session_completer( @@ -62,7 +85,7 @@ def test_session_completer(self): ) # if noxfile.py changes, this will have to change as well since these are # some of the actual sessions found in noxfile.py - some_expected_sessions = ["cover", "blacken", "lint", "docs"] + some_expected_sessions = ["blacken", "lint", "docs"] assert len(set(some_expected_sessions) - set(all_nox_sessions)) == 0 def test_session_completer_invalid_sessions(self): diff --git a/tests/test_main.py b/tests/test_main.py index 15f7b506..0b61e804 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -542,8 +542,8 @@ def generate_noxfile(default_session, default_python, alternate_python): return generate_noxfile -python_current_version = "{}.{}".format(sys.version_info.major, sys.version_info.minor) -python_next_version = "{}.{}".format(sys.version_info.major, sys.version_info.minor + 1) +python_current_version = f"{sys.version_info.major}.{sys.version_info.minor}" +python_next_version = f"{sys.version_info.major}.{sys.version_info.minor + 1}" def test_main_noxfile_options_with_pythons_override( @@ -566,7 +566,7 @@ def test_main_noxfile_options_with_pythons_override( for python_version in [python_current_version, python_next_version]: for session in ["test", "launch_rocket"]: - line = "Running session {}-{}".format(session, python_version) + line = f"Running session {session}-{python_version}" if session == "test" and python_version == python_current_version: assert line in stderr else: @@ -593,7 +593,7 @@ def test_main_noxfile_options_with_sessions_override( for python_version in [python_current_version, python_next_version]: for session in ["test", "launch_rocket"]: - line = "Running session {}-{}".format(session, python_version) + line = f"Running session {session}-{python_version}" if session == "launch_rocket" and python_version == python_current_version: assert line in stderr else: diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 89df734e..3c949e64 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -67,7 +67,10 @@ def make_session_and_runner(self): signatures=["test"], func=func, global_config=_options.options.namespace( - posargs=[], error_on_external_run=False, install_only=False + posargs=[], + error_on_external_run=False, + install_only=False, + invoked_from=os.getcwd(), ), manifest=mock.create_autospec(nox.manifest.Manifest), ) @@ -96,14 +99,20 @@ def test_create_tmp_twice(self): def test_properties(self): session, runner = self.make_session_and_runner() + with tempfile.TemporaryDirectory() as root: + runner.global_config.envdir = root - assert session.name is runner.friendly_name - assert session.env is runner.venv.env - assert session.posargs == runner.global_config.posargs - assert session.virtualenv is runner.venv - assert session.bin_paths is runner.venv.bin_paths - assert session.bin is runner.venv.bin_paths[0] - assert session.python is runner.func.python + assert session.name is runner.friendly_name + assert session.env is runner.venv.env + assert session.posargs == runner.global_config.posargs + assert session.virtualenv is runner.venv + assert session.bin_paths is runner.venv.bin_paths + assert session.bin is runner.venv.bin_paths[0] + assert session.python is runner.func.python + assert session.invoked_from is runner.global_config.invoked_from + assert session.cache_dir == Path(runner.global_config.envdir).joinpath( + ".cache" + ) def test_no_bin_paths(self): session, runner = self.make_session_and_runner() @@ -155,6 +164,17 @@ def test_chdir(self, tmpdir): assert os.getcwd() == cdto os.chdir(current_cwd) + def test_invoked_from(self, tmpdir): + cdto = str(tmpdir.join("cdbby").ensure(dir=True)) + current_cwd = os.getcwd() + + session, _ = self.make_session_and_runner() + + session.chdir(cdto) + + assert session.invoked_from == current_cwd + os.chdir(current_cwd) + def test_chdir_pathlib(self, tmpdir): cdto = str(tmpdir.join("cdbby").ensure(dir=True)) current_cwd = os.getcwd() @@ -450,7 +470,7 @@ class SessionNoSlots(nox.sessions.Session): pkg_requirement = passed_arg = "urllib3" elif version_constraint == "yes": pkg_requirement = "urllib3<1.25" - passed_arg = '"%s"' % pkg_requirement + passed_arg = f'"{pkg_requirement}"' elif version_constraint == "already_dbl_quoted": pkg_requirement = passed_arg = '"urllib3<1.25"' else: diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 9827cfc8..c8edb8a7 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -18,6 +18,7 @@ import json import os import platform +from pathlib import Path from textwrap import dedent from unittest import mock @@ -76,9 +77,48 @@ def test_load_nox_module_expandvars(): assert noxfile_module.SIGIL == "123" -def test_load_nox_module_not_found(): - config = _options.options.namespace(noxfile="bogus.py") +def test_load_nox_module_not_found(caplog, tmp_path): + bogus_noxfile = tmp_path / "bogus.py" + config = _options.options.namespace(noxfile=str(bogus_noxfile)) + assert tasks.load_nox_module(config) == 2 + assert ( + f"Failed to load Noxfile {bogus_noxfile}, no such file exists." in caplog.text + ) + + +def test_load_nox_module_IOError(caplog): + + # Need to give it a noxfile that exists so load_nox_module can progress + # past FileNotFoundError + # use our own noxfile.py for this + our_noxfile = Path(__file__).parent.parent.joinpath("noxfile.py") + config = _options.options.namespace(noxfile=str(our_noxfile)) + + with mock.patch( + "nox.tasks.importlib.machinery.SourceFileLoader.load_module" + ) as mock_load: + mock_load.side_effect = IOError + + assert tasks.load_nox_module(config) == 2 + assert "Failed to load Noxfile" in caplog.text + + +def test_load_nox_module_OSError(caplog): + + # Need to give it a noxfile that exists so load_nox_module can progress + # past FileNotFoundError + # use our own noxfile.py for this + our_noxfile = Path(__file__).parent.parent.joinpath("noxfile.py") + config = _options.options.namespace(noxfile=str(our_noxfile)) + + with mock.patch( + "nox.tasks.importlib.machinery.SourceFileLoader.load_module" + ) as mock_load: + mock_load.side_effect = OSError + + assert tasks.load_nox_module(config) == 2 + assert "Failed to load Noxfile" in caplog.text @pytest.fixture @@ -200,12 +240,21 @@ def test_honor_list_request_noop(): assert return_value is manifest -@pytest.mark.parametrize("description", [None, "bar"]) -def test_honor_list_request(description): +@pytest.mark.parametrize( + "description, module_docstring", + [ + (None, None), + (None, "hello docstring"), + ("Bar", None), + ("Bar", "hello docstring"), + ], +) +def test_honor_list_request(description, module_docstring): config = _options.options.namespace( list_sessions=True, noxfile="noxfile.py", color=False ) manifest = mock.create_autospec(Manifest) + manifest.module_docstring = module_docstring manifest.list_all_sessions.return_value = [ (argparse.Namespace(friendly_name="foo", description=description), True) ] @@ -218,6 +267,7 @@ def test_honor_list_request_skip_and_selected(capsys): list_sessions=True, noxfile="noxfile.py", color=False ) manifest = mock.create_autospec(Manifest) + manifest.module_docstring = None manifest.list_all_sessions.return_value = [ (argparse.Namespace(friendly_name="foo", description=None), True), (argparse.Namespace(friendly_name="bar", description=None), False), @@ -231,6 +281,44 @@ def test_honor_list_request_skip_and_selected(capsys): assert "- bar" in out +def test_honor_list_request_prints_docstring_if_present(capsys): + config = _options.options.namespace( + list_sessions=True, noxfile="noxfile.py", color=False + ) + manifest = mock.create_autospec(Manifest) + manifest.module_docstring = "Hello I'm a docstring" + manifest.list_all_sessions.return_value = [ + (argparse.Namespace(friendly_name="foo", description=None), True), + (argparse.Namespace(friendly_name="bar", description=None), False), + ] + + return_value = tasks.honor_list_request(manifest, global_config=config) + assert return_value == 0 + + out = capsys.readouterr().out + + assert "Hello I'm a docstring" in out + + +def test_honor_list_request_doesnt_print_docstring_if_not_present(capsys): + config = _options.options.namespace( + list_sessions=True, noxfile="noxfile.py", color=False + ) + manifest = mock.create_autospec(Manifest) + manifest.module_docstring = None + manifest.list_all_sessions.return_value = [ + (argparse.Namespace(friendly_name="foo", description=None), True), + (argparse.Namespace(friendly_name="bar", description=None), False), + ] + + return_value = tasks.honor_list_request(manifest, global_config=config) + assert return_value == 0 + + out = capsys.readouterr().out + + assert "Hello I'm a docstring" not in out + + def test_verify_manifest_empty(): config = _options.options.namespace(sessions=(), keywords=()) manifest = Manifest({}, config) diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py index ac0fa598..8fe1c410 100644 --- a/tests/test_virtualenv.py +++ b/tests/test_virtualenv.py @@ -210,6 +210,23 @@ def test_condaenv_create_interpreter(make_conda): assert dir_.join("bin", "python3.7").check() +@pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.") +def test_conda_env_create_verbose(make_conda): + venv, dir_ = make_conda() + with mock.patch("nox.virtualenv.nox.command.run") as mock_run: + venv.create() + + args, kwargs = mock_run.call_args + assert kwargs["log"] is False + + nox.options.verbose = True + with mock.patch("nox.virtualenv.nox.command.run") as mock_run: + venv.create() + + args, kwargs = mock_run.call_args + assert kwargs["log"] + + @mock.patch("nox.virtualenv._SYSTEM", new="Windows") def test_condaenv_bin_windows(make_conda): venv, dir_ = make_conda() @@ -335,7 +352,6 @@ def test_create(monkeypatch, make_one): dir_.ensure("test.txt") assert dir_.join("test.txt").check() venv.reuse_existing = True - monkeypatch.setattr(nox.virtualenv.nox.command, "run", mock.MagicMock()) venv.create() @@ -352,6 +368,15 @@ def test_create_reuse_environment(make_one): assert reused +@pytest.fixture +def _enable_staleness_check(monkeypatch): + monkeypatch.setattr("nox.virtualenv._ENABLE_STALENESS_CHECK", True) + + +enable_staleness_check = pytest.mark.usefixtures("_enable_staleness_check") + + +@enable_staleness_check def test_create_reuse_environment_with_different_interpreter(make_one, monkeypatch): venv, location = make_one(reuse_existing=True) venv.create() @@ -368,6 +393,7 @@ def test_create_reuse_environment_with_different_interpreter(make_one, monkeypat assert not location.join("marker").check() +@enable_staleness_check def test_create_reuse_stale_venv_environment(make_one): venv, location = make_one(reuse_existing=True) venv.create() @@ -387,6 +413,7 @@ def test_create_reuse_stale_venv_environment(make_one): assert not reused +@enable_staleness_check def test_create_reuse_stale_virtualenv_environment(make_one): venv, location = make_one(reuse_existing=True, venv=True) venv.create() @@ -411,6 +438,7 @@ def test_create_reuse_stale_virtualenv_environment(make_one): assert not reused +@enable_staleness_check def test_create_reuse_venv_environment(make_one): venv, location = make_one(reuse_existing=True, venv=True) venv.create() @@ -425,6 +453,7 @@ def test_create_reuse_venv_environment(make_one): assert reused +@enable_staleness_check @pytest.mark.skipif(IS_WINDOWS, reason="Avoid 'No pyvenv.cfg file' error on Windows.") def test_create_reuse_oldstyle_virtualenv_environment(make_one): venv, location = make_one(reuse_existing=True) @@ -443,6 +472,20 @@ def test_create_reuse_oldstyle_virtualenv_environment(make_one): assert reused +@enable_staleness_check +def test_create_reuse_python2_environment(make_one): + venv, location = make_one(reuse_existing=True, interpreter="2.7") + + try: + venv.create() + except nox.virtualenv.InterpreterNotFound: + pytest.skip("Requires Python 2.7 installation.") + + reused = not venv.create() + + assert reused + + def test_create_venv_backend(make_one): venv, dir_ = make_one(venv=True) venv.create()