From 885a1aa8921097309b89954fa31fbb8fae4cb08c Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 10:10:25 +0100 Subject: [PATCH 01/18] feat: Add nox.needs_version --- nox/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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"] From 78ffeff89c8bf849d371785e137dfeaff8c4f81a Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 10:21:06 +0100 Subject: [PATCH 02/18] test: Add test for nox.needs_version --- tests/test__version.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/test__version.py diff --git a/tests/test__version.py b/tests/test__version.py new file mode 100644 index 00000000..68e0d9ab --- /dev/null +++ b/tests/test__version.py @@ -0,0 +1,20 @@ +# 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 nox import needs_version + + +def test_needs_version_default() -> None: + """It is None by default.""" + assert needs_version is None From cdf3599d6f78f3894105553752b329df51114632 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 10:15:36 +0100 Subject: [PATCH 03/18] feat(_version): Add module with get_nox_version() --- nox/_version.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 nox/_version.py diff --git a/nox/_version.py b/nox/_version.py new file mode 100644 index 00000000..d7f8206e --- /dev/null +++ b/nox/_version.py @@ -0,0 +1,23 @@ +# 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. + +try: + import importlib.metadata as metadata +except ImportError: # pragma: no cover + import importlib_metadata as metadata + + +def get_nox_version() -> str: + """Return the version of the installed Nox package.""" + return metadata.version("nox") From 57922a6726bee1b17bbc8e56b3fb64c2eedbfa61 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 10:23:15 +0100 Subject: [PATCH 04/18] test(_version): Add test for get_nox_version --- tests/test__version.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test__version.py b/tests/test__version.py index 68e0d9ab..6b8d62c1 100644 --- a/tests/test__version.py +++ b/tests/test__version.py @@ -13,8 +13,17 @@ # limitations under the License. from nox import needs_version +from nox._version import get_nox_version + 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 From 5b72c8ce40a48ef92e43b477a71e2e17e9cf246d Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 10:11:28 +0100 Subject: [PATCH 05/18] refactor(__main__): Use _version.get_nox_version --- nox/__main__.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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( From b768b1db3ee863d04a06dbccda05df101aa30977 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 10:17:41 +0100 Subject: [PATCH 06/18] feat(_version): Add _{parse,read}_needs_version() --- nox/_version.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/nox/_version.py b/nox/_version.py index d7f8206e..ca85346a 100644 --- a/nox/_version.py +++ b/nox/_version.py @@ -12,6 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +import ast +import sys +from typing import Optional + try: import importlib.metadata as metadata except ImportError: # pragma: no cover @@ -21,3 +25,38 @@ 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]: + """Return the value of a string constant.""" + if sys.version_info < (3, 8): # pragma: no cover + 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 # pragma: no cover + + +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) From 2e492b38548e637cc6a88fb7c4e5342695e8d194 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 10:24:41 +0100 Subject: [PATCH 07/18] test(_version): Add test for _parse_needs_version --- tests/test__version.py | 55 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/tests/test__version.py b/tests/test__version.py index 6b8d62c1..07ad073c 100644 --- a/tests/test__version.py +++ b/tests/test__version.py @@ -12,9 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from nox import needs_version -from nox._version import get_nox_version +from textwrap import dedent +from typing import Optional +import pytest +from nox import needs_version +from nox._version import _parse_needs_version, get_nox_version def test_needs_version_default() -> None: @@ -27,3 +30,51 @@ def test_get_nox_version() -> None: 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) From 90befd5b1aacea7b41aa27bbbc03df920a5f6989 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Sun, 14 Feb 2021 16:22:47 +0100 Subject: [PATCH 08/18] build: Add dependency on packaging >= 20.9 The packaging library is needed to parse the version specifier in the `nox.needs_version` attribute set by user's `noxfile.py` files. We use the current version as the lower bound. The upper bound is omitted; packaging uses calendar versioning with a YY.N scheme. --- setup.py | 1 + 1 file changed, 1 insertion(+) 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'", From f74839dd81ac7193786381bd110e697d9a9e4752 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 10:18:19 +0100 Subject: [PATCH 09/18] feat(_version): Add check_nox_version() --- nox/_version.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/nox/_version.py b/nox/_version.py index ca85346a..8736af88 100644 --- a/nox/_version.py +++ b/nox/_version.py @@ -13,15 +13,27 @@ # 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") @@ -60,3 +72,46 @@ def _read_needs_version(filename: str) -> Optional[str]: 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: Optional[str] = None) -> None: + """Check if ``nox.needs_version`` in the user's noxfile is satisfied. + + Args: + filename: The location of the user's noxfile. When specified, read + ``nox.needs_version`` from the noxfile by parsing the AST. + Otherwise, assume that the noxfile was already imported, and + use ``nox.needs_version`` directly. + + Raises: + VersionCheckFailed: The Nox version does not satisfy what + ``nox.needs_version`` specifies. + InvalidVersionSpecifier: The ``nox.needs_version`` specifier cannot be + parsed. + """ + from nox import needs_version + + if filename is not None: + needs_version = _read_needs_version(filename) # noqa: F811 + + if needs_version is not None: + _check_nox_version_satisfies(needs_version) From 6a401c4ada270e939ceea60c101de5b05a4358e4 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 10:25:22 +0100 Subject: [PATCH 10/18] test(_version): Add test for check_nox_version --- tests/test__version.py | 53 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/test__version.py b/tests/test__version.py index 07ad073c..2c0ce251 100644 --- a/tests/test__version.py +++ b/tests/test__version.py @@ -12,12 +12,19 @@ # 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 _parse_needs_version, get_nox_version +from nox._version import ( + InvalidVersionSpecifier, + VersionCheckFailed, + _parse_needs_version, + check_nox_version, + get_nox_version, +) def test_needs_version_default() -> None: @@ -78,3 +85,47 @@ def test_get_nox_version() -> 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(tmp_path: Path, specifiers: str) -> None: + """It does not raise if the version specifiers are satisfied.""" + text = dedent( + f""" + import nox + nox.needs_version = "{specifiers}" + """ + ) + path = tmp_path / "noxfile.py" + path.write_text(text) + check_nox_version(str(path)) + + +@pytest.mark.parametrize("specifiers", [">=9999.99.99"]) +def test_check_nox_version_fails(tmp_path: Path, specifiers: str) -> None: + """It raises an exception if the version specifiers are not satisfied.""" + text = dedent( + f""" + import nox + nox.needs_version = "{specifiers}" + """ + ) + path = tmp_path / "noxfile.py" + path.write_text(text) + with pytest.raises(VersionCheckFailed): + check_nox_version(str(path)) + + +@pytest.mark.parametrize("specifiers", ["invalid", "2020.12.31"]) +def test_check_nox_version_invalid(tmp_path: Path, specifiers: str) -> None: + """It raises an exception if the version specifiers cannot be parsed.""" + text = dedent( + f""" + import nox + nox.needs_version = "{specifiers}" + """ + ) + path = tmp_path / "noxfile.py" + path.write_text(text) + with pytest.raises(InvalidVersionSpecifier): + check_nox_version(str(path)) From 2e03ab08bf40c273b4b8cf535fa85151a9643ce8 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 10:11:47 +0100 Subject: [PATCH 11/18] feat(tasks): Check nox.needs_version in load_nox_module --- nox/tasks.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/nox/tasks.py b/nox/tasks.py index 31d4c826..95b87fcb 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,15 +52,26 @@ 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 # guess. os.chdir(os.path.realpath(os.path.dirname(global_config.noxfile))) - return importlib.machinery.SourceFileLoader( + module = importlib.machinery.SourceFileLoader( "user_nox_module", global_config.noxfile ).load_module() # type: ignore + # Check ``nox.needs_version`` as set by the Noxfile. + check_nox_version() + + return module + + 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 From 162afa0fb0426ba7e551d678a47d89fe7e7a4be5 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 10:12:06 +0100 Subject: [PATCH 12/18] test(tasks): Add tests for load_nox_module with needs_version --- tests/test_tasks.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 88c5fc4d..e3e364f9 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -18,6 +18,8 @@ import json import os import platform +from pathlib import Path +from textwrap import dedent from unittest import mock import nox @@ -79,6 +81,31 @@ def test_load_nox_module_not_found(): assert tasks.load_nox_module(config) == 2 +@pytest.mark.parametrize( + "text", + [ + dedent( + """ + import nox + nox.needs_version = ">=9999.99.99" + """ + ), + dedent( + """ + import nox + NOX_NEEDS_VERSION = ">=9999.99.99" + nox.needs_version = NOX_NEEDS_VERSION + """ + ), + ], +) +def test_load_nox_module_needs_version(text: str, tmp_path: Path): + 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_discover_session_functions_decorator(): # Define sessions using the decorator. @nox.session From db0cbb8336e585a000f512220afeb529af681017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulius=20Maru=C5=A1ka?= Date: Mon, 8 Jul 2019 23:17:14 +0200 Subject: [PATCH 13/18] docs(config.rst): Add section "Nox version requirements" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Paulius Maruška --- docs/config.rst | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/config.rst b/docs/config.rst index 99580d17..b02fa29e 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,28 @@ 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.py`` 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. If you assign a +string literal like in the example above, Nox is able to check the version +without importing the Noxfile. + +.. _PEP 440: https://www.python.org/dev/peps/pep-0440/ From 2681677d5010f8c945a2a2ae2fd95378de7a9caa Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 11:29:48 +0100 Subject: [PATCH 14/18] chore(coverage): Exclude _parse_string_constant from coverage Excluding only the Python < 3.8 branch from coverage does not work, as the CI jobs for Python 3.6 and 3.7 will still fail. Take a pragmatic approach for now. We do have test coverage for the main two branches of this function (not the final `return None`), but fixing this would require combining coverage data in CI, or using coverage-conditional-plugin. --- nox/_version.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nox/_version.py b/nox/_version.py index 8736af88..a764037e 100644 --- a/nox/_version.py +++ b/nox/_version.py @@ -39,14 +39,14 @@ def get_nox_version() -> str: return metadata.version("nox") -def _parse_string_constant(node: ast.AST) -> Optional[str]: +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): # pragma: no cover + 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 # pragma: no cover + return None def _parse_needs_version(source: str, filename: str = "") -> Optional[str]: From 966a39f0d06f24c9cdffe48342026c1d0e567b47 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Wed, 17 Feb 2021 19:59:47 +0100 Subject: [PATCH 15/18] style(_version): Fix capitalization of Nox in error message --- nox/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nox/_version.py b/nox/_version.py index a764037e..48a5b141 100644 --- a/nox/_version.py +++ b/nox/_version.py @@ -89,7 +89,7 @@ def _check_nox_version_satisfies(needs_version: str) -> None: if not specifiers.contains(version, prereleases=True): raise VersionCheckFailed( - f"The Noxfile requires nox {specifiers}, you have {version}" + f"The Noxfile requires Nox {specifiers}, you have {version}" ) From 3b879a82c7f88e23083b6965a305dcb3275ee2be Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Sat, 20 Feb 2021 11:13:35 +0100 Subject: [PATCH 16/18] Require `nox.needs_version` to be specified statically Drop the two-phase approach and support only version requirements that can be determined by parsing the AST of the user's Noxfile. --- docs/config.rst | 9 ++++---- nox/_version.py | 14 +++++------- nox/tasks.py | 7 +----- tests/test_tasks.py | 52 ++++++++++++++++++++++++++++----------------- 4 files changed, 43 insertions(+), 39 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index b02fa29e..fd326cef 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -398,7 +398,7 @@ When invoking ``nox``, any options specified on the command line take precedence Nox version requirements ------------------------ -Nox version requirements can be specified in your ``noxfile.py`` by setting +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: @@ -412,9 +412,10 @@ exits with a friendly error message. For example: def pytest(session): session.run("pytest") +Any of the version specifiers defined in `PEP 440`_ can be used. -Any of the version specifiers defined in `PEP 440`_ can be used. If you assign a -string literal like in the example above, Nox is able to check the version -without importing the Noxfile. +**Important**: 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/_version.py b/nox/_version.py index 48a5b141..296f2e67 100644 --- a/nox/_version.py +++ b/nox/_version.py @@ -93,14 +93,13 @@ def _check_nox_version_satisfies(needs_version: str) -> None: ) -def check_nox_version(filename: Optional[str] = None) -> None: +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. When specified, read - ``nox.needs_version`` from the noxfile by parsing the AST. - Otherwise, assume that the noxfile was already imported, and - use ``nox.needs_version`` directly. + + 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 @@ -108,10 +107,7 @@ def check_nox_version(filename: Optional[str] = None) -> None: InvalidVersionSpecifier: The ``nox.needs_version`` specifier cannot be parsed. """ - from nox import needs_version - - if filename is not None: - needs_version = _read_needs_version(filename) # noqa: F811 + 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 95b87fcb..c8d99b0b 100644 --- a/nox/tasks.py +++ b/nox/tasks.py @@ -60,15 +60,10 @@ def load_nox_module(global_config: Namespace) -> Union[types.ModuleType, int]: # import-time path resolutions work the way the Noxfile author would # guess. os.chdir(os.path.realpath(os.path.dirname(global_config.noxfile))) - module = importlib.machinery.SourceFileLoader( + return importlib.machinery.SourceFileLoader( "user_nox_module", global_config.noxfile ).load_module() # type: ignore - # Check ``nox.needs_version`` as set by the Noxfile. - check_nox_version() - - return module - except (VersionCheckFailed, InvalidVersionSpecifier) as error: logger.error(str(error)) return 2 diff --git a/tests/test_tasks.py b/tests/test_tasks.py index e3e364f9..65f3112d 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -18,7 +18,6 @@ import json import os import platform -from pathlib import Path from textwrap import dedent from unittest import mock @@ -81,31 +80,44 @@ def test_load_nox_module_not_found(): assert tasks.load_nox_module(config) == 2 -@pytest.mark.parametrize( - "text", - [ - dedent( - """ - import nox - nox.needs_version = ">=9999.99.99" - """ - ), - dedent( - """ - import nox - NOX_NEEDS_VERSION = ">=9999.99.99" - nox.needs_version = NOX_NEEDS_VERSION - """ - ), - ], -) -def test_load_nox_module_needs_version(text: str, tmp_path: Path): +@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 From 0f26270784dce7f64f3efda63a11ee6c4a88ffc2 Mon Sep 17 00:00:00 2001 From: Thea Flowers Date: Sat, 20 Feb 2021 12:39:48 -0500 Subject: [PATCH 17/18] Use admonition for version specification warning --- docs/config.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index fd326cef..a8da73db 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -414,8 +414,8 @@ exits with a friendly error message. For example: Any of the version specifiers defined in `PEP 440`_ can be used. -**Important**: 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. +.. 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/ From 0b027290e3a535d90584d420d7c5108487b39aa8 Mon Sep 17 00:00:00 2001 From: Thea Flowers Date: Sat, 20 Feb 2021 12:53:56 -0500 Subject: [PATCH 18/18] Use a fixture to take care of common noxfile creation code --- tests/test__version.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/test__version.py b/tests/test__version.py index 2c0ce251..2606952c 100644 --- a/tests/test__version.py +++ b/tests/test__version.py @@ -27,6 +27,16 @@ ) +@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 @@ -88,7 +98,7 @@ def test_parse_needs_version(text: str, expected: Optional[str]) -> None: @pytest.mark.parametrize("specifiers", ["", ">=2020.12.31", ">=2020.12.31,<9999.99.99"]) -def test_check_nox_version_succeeds(tmp_path: Path, specifiers: str) -> None: +def test_check_nox_version_succeeds(temp_noxfile, specifiers: str) -> None: """It does not raise if the version specifiers are satisfied.""" text = dedent( f""" @@ -96,13 +106,11 @@ def test_check_nox_version_succeeds(tmp_path: Path, specifiers: str) -> None: nox.needs_version = "{specifiers}" """ ) - path = tmp_path / "noxfile.py" - path.write_text(text) - check_nox_version(str(path)) + check_nox_version(temp_noxfile(text)) @pytest.mark.parametrize("specifiers", [">=9999.99.99"]) -def test_check_nox_version_fails(tmp_path: Path, specifiers: str) -> None: +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""" @@ -110,14 +118,12 @@ def test_check_nox_version_fails(tmp_path: Path, specifiers: str) -> None: nox.needs_version = "{specifiers}" """ ) - path = tmp_path / "noxfile.py" - path.write_text(text) with pytest.raises(VersionCheckFailed): - check_nox_version(str(path)) + check_nox_version(temp_noxfile(text)) @pytest.mark.parametrize("specifiers", ["invalid", "2020.12.31"]) -def test_check_nox_version_invalid(tmp_path: Path, specifiers: str) -> None: +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""" @@ -125,7 +131,5 @@ def test_check_nox_version_invalid(tmp_path: Path, specifiers: str) -> None: nox.needs_version = "{specifiers}" """ ) - path = tmp_path / "noxfile.py" - path.write_text(text) with pytest.raises(InvalidVersionSpecifier): - check_nox_version(str(path)) + check_nox_version(temp_noxfile(text))