diff --git a/docs/config.rst b/docs/config.rst index 99580d17..a8da73db 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -339,7 +339,6 @@ Produces these sessions when running ``nox --list``: * tests(mysql, new) - The session object ------------------ @@ -394,3 +393,29 @@ The following options can be specified in the Noxfile: When invoking ``nox``, any options specified on the command line take precedence over the options specified in the Noxfile. If either ``--sessions`` or ``--keywords`` is specified on the command line, *both* options specified in the Noxfile will be ignored. + + +Nox version requirements +------------------------ + +Nox version requirements can be specified in your Noxfile by setting +``nox.needs_version``. If the Nox version does not satisfy the requirements, Nox +exits with a friendly error message. For example: + +.. code-block:: python + + import nox + + nox.needs_version = ">=2019.5.30" + + @nox.session(name="test") # name argument was added in 2019.5.30 + def pytest(session): + session.run("pytest") + +Any of the version specifiers defined in `PEP 440`_ can be used. + +.. warning:: Version requirements *must* be specified as a string literal, + using a simple assignment to ``nox.needs_version`` at the module level. This + allows Nox to check the version without importing the Noxfile. + +.. _PEP 440: https://www.python.org/dev/peps/pep-0440/ diff --git a/nox/__init__.py b/nox/__init__.py index 75392036..78c5283d 100644 --- a/nox/__init__.py +++ b/nox/__init__.py @@ -12,10 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Optional + from nox._options import noxfile_options as options from nox._parametrize import Param as param from nox._parametrize import parametrize_decorator as parametrize from nox.registry import session_decorator as session from nox.sessions import Session -__all__ = ["parametrize", "param", "session", "options", "Session"] +needs_version: Optional[str] = None + +__all__ = ["needs_version", "parametrize", "param", "session", "options", "Session"] diff --git a/nox/__main__.py b/nox/__main__.py index 2f551cca..d6043ac0 100644 --- a/nox/__main__.py +++ b/nox/__main__.py @@ -22,13 +22,9 @@ import sys from nox import _options, tasks, workflow +from nox._version import get_nox_version from nox.logger import setup_logging -try: - import importlib.metadata as metadata -except ImportError: # pragma: no cover - import importlib_metadata as metadata - def main() -> None: args = _options.options.parse_args() @@ -38,7 +34,7 @@ def main() -> None: return if args.version: - print(metadata.version("nox"), file=sys.stderr) + print(get_nox_version(), file=sys.stderr) return setup_logging( diff --git a/nox/_version.py b/nox/_version.py new file mode 100644 index 00000000..296f2e67 --- /dev/null +++ b/nox/_version.py @@ -0,0 +1,113 @@ +# Copyright 2021 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. + +import ast +import contextlib +import sys +from typing import Optional + +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version + +try: + import importlib.metadata as metadata +except ImportError: # pragma: no cover + import importlib_metadata as metadata + + +class VersionCheckFailed(Exception): + """The Nox version does not satisfy what ``nox.needs_version`` specifies.""" + + +class InvalidVersionSpecifier(Exception): + """The ``nox.needs_version`` specifier cannot be parsed.""" + + +def get_nox_version() -> str: + """Return the version of the installed Nox package.""" + return metadata.version("nox") + + +def _parse_string_constant(node: ast.AST) -> Optional[str]: # pragma: no cover + """Return the value of a string constant.""" + if sys.version_info < (3, 8): + if isinstance(node, ast.Str) and isinstance(node.s, str): + return node.s + elif isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def _parse_needs_version(source: str, filename: str = "") -> Optional[str]: + """Parse ``nox.needs_version`` from the user's noxfile.""" + value: Optional[str] = None + module: ast.Module = ast.parse(source, filename=filename) + for statement in module.body: + if isinstance(statement, ast.Assign): + for target in statement.targets: + if ( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "nox" + and target.attr == "needs_version" + ): + value = _parse_string_constant(statement.value) + return value + + +def _read_needs_version(filename: str) -> Optional[str]: + """Read ``nox.needs_version`` from the user's noxfile.""" + with open(filename) as io: + source = io.read() + + return _parse_needs_version(source, filename=filename) + + +def _check_nox_version_satisfies(needs_version: str) -> None: + """Check if the Nox version satisfies the given specifiers.""" + version = Version(get_nox_version()) + + try: + specifiers = SpecifierSet(needs_version) + except InvalidSpecifier as error: + message = f"Cannot parse `nox.needs_version`: {error}" + with contextlib.suppress(InvalidVersion): + Version(needs_version) + message += f", did you mean '>= {needs_version}'?" + raise InvalidVersionSpecifier(message) + + if not specifiers.contains(version, prereleases=True): + raise VersionCheckFailed( + f"The Noxfile requires Nox {specifiers}, you have {version}" + ) + + +def check_nox_version(filename: str) -> None: + """Check if ``nox.needs_version`` in the user's noxfile is satisfied. + + Args: + + filename: The location of the user's noxfile. ``nox.needs_version`` is + read from the noxfile by parsing the AST. + + Raises: + VersionCheckFailed: The Nox version does not satisfy what + ``nox.needs_version`` specifies. + InvalidVersionSpecifier: The ``nox.needs_version`` specifier cannot be + parsed. + """ + needs_version = _read_needs_version(filename) + + if needs_version is not None: + _check_nox_version_satisfies(needs_version) diff --git a/nox/tasks.py b/nox/tasks.py index 31d4c826..c8d99b0b 100644 --- a/nox/tasks.py +++ b/nox/tasks.py @@ -23,6 +23,7 @@ import nox from colorlog.escape_codes import parse_colors from nox import _options, registry +from nox._version import InvalidVersionSpecifier, VersionCheckFailed, check_nox_version from nox.logger import logger from nox.manifest import WARN_PYTHONS_IGNORED, Manifest from nox.sessions import Result @@ -51,6 +52,9 @@ def load_nox_module(global_config: Namespace) -> Union[types.ModuleType, int]: os.path.expandvars(global_config.noxfile) ) + # Check ``nox.needs_version`` by parsing the AST. + check_nox_version(global_config.noxfile) + # 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 @@ -60,6 +64,9 @@ def load_nox_module(global_config: Namespace) -> Union[types.ModuleType, int]: "user_nox_module", global_config.noxfile ).load_module() # type: ignore + except (VersionCheckFailed, InvalidVersionSpecifier) as error: + logger.error(str(error)) + return 2 except (IOError, OSError): logger.exception("Failed to load Noxfile {}".format(global_config.noxfile)) return 2 diff --git a/setup.py b/setup.py index cd8d73a7..c8de26b1 100644 --- a/setup.py +++ b/setup.py @@ -52,6 +52,7 @@ install_requires=[ "argcomplete>=1.9.4,<2.0", "colorlog>=2.6.1,<5.0.0", + "packaging>=20.9", "py>=1.4.0,<2.0.0", "virtualenv>=14.0.0", "importlib_metadata; python_version < '3.8'", diff --git a/tests/test__version.py b/tests/test__version.py new file mode 100644 index 00000000..2606952c --- /dev/null +++ b/tests/test__version.py @@ -0,0 +1,135 @@ +# Copyright 2021 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 pathlib import Path +from textwrap import dedent +from typing import Optional + +import pytest +from nox import needs_version +from nox._version import ( + InvalidVersionSpecifier, + VersionCheckFailed, + _parse_needs_version, + check_nox_version, + get_nox_version, +) + + +@pytest.fixture +def temp_noxfile(tmp_path: Path): + def make_temp_noxfile(content: str) -> str: + path = tmp_path / "noxfile.py" + path.write_text(content) + return str(path) + + return make_temp_noxfile + + +def test_needs_version_default() -> None: + """It is None by default.""" + assert needs_version is None + + +def test_get_nox_version() -> None: + """It returns something that looks like a Nox version.""" + result = get_nox_version() + year, month, day = [int(part) for part in result.split(".")[:3]] + assert year >= 2020 + + +@pytest.mark.parametrize( + "text,expected", + [ + ("", None), + ( + dedent( + """ + import nox + nox.needs_version = '>=2020.12.31' + """ + ), + ">=2020.12.31", + ), + ( + dedent( + """ + import nox + nox.needs_version = 'bogus' + nox.needs_version = '>=2020.12.31' + """ + ), + ">=2020.12.31", + ), + ( + dedent( + """ + import nox.sessions + nox.needs_version = '>=2020.12.31' + """ + ), + ">=2020.12.31", + ), + ( + dedent( + """ + import nox as _nox + _nox.needs_version = '>=2020.12.31' + """ + ), + None, + ), + ], +) +def test_parse_needs_version(text: str, expected: Optional[str]) -> None: + """It is parsed successfully.""" + assert expected == _parse_needs_version(text) + + +@pytest.mark.parametrize("specifiers", ["", ">=2020.12.31", ">=2020.12.31,<9999.99.99"]) +def test_check_nox_version_succeeds(temp_noxfile, specifiers: str) -> None: + """It does not raise if the version specifiers are satisfied.""" + text = dedent( + f""" + import nox + nox.needs_version = "{specifiers}" + """ + ) + check_nox_version(temp_noxfile(text)) + + +@pytest.mark.parametrize("specifiers", [">=9999.99.99"]) +def test_check_nox_version_fails(temp_noxfile, specifiers: str) -> None: + """It raises an exception if the version specifiers are not satisfied.""" + text = dedent( + f""" + import nox + nox.needs_version = "{specifiers}" + """ + ) + with pytest.raises(VersionCheckFailed): + check_nox_version(temp_noxfile(text)) + + +@pytest.mark.parametrize("specifiers", ["invalid", "2020.12.31"]) +def test_check_nox_version_invalid(temp_noxfile, specifiers: str) -> None: + """It raises an exception if the version specifiers cannot be parsed.""" + text = dedent( + f""" + import nox + nox.needs_version = "{specifiers}" + """ + ) + with pytest.raises(InvalidVersionSpecifier): + check_nox_version(temp_noxfile(text)) diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 88c5fc4d..65f3112d 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -18,6 +18,7 @@ import json import os import platform +from textwrap import dedent from unittest import mock import nox @@ -79,6 +80,44 @@ def test_load_nox_module_not_found(): assert tasks.load_nox_module(config) == 2 +@pytest.fixture +def reset_needs_version(): + """Do not leak ``nox.needs_version`` between tests.""" + try: + yield + finally: + nox.needs_version = None + + +def test_load_nox_module_needs_version_static(reset_needs_version, tmp_path): + text = dedent( + """ + import nox + nox.needs_version = ">=9999.99.99" + """ + ) + noxfile = tmp_path / "noxfile.py" + noxfile.write_text(text) + config = _options.options.namespace(noxfile=str(noxfile)) + assert tasks.load_nox_module(config) == 2 + + +def test_load_nox_module_needs_version_dynamic(reset_needs_version, tmp_path): + text = dedent( + """ + import nox + NOX_NEEDS_VERSION = ">=9999.99.99" + nox.needs_version = NOX_NEEDS_VERSION + """ + ) + noxfile = tmp_path / "noxfile.py" + noxfile.write_text(text) + config = _options.options.namespace(noxfile=str(noxfile)) + tasks.load_nox_module(config) + # Dynamic version requirements are not checked. + assert nox.needs_version == ">=9999.99.99" + + def test_discover_session_functions_decorator(): # Define sessions using the decorator. @nox.session