Repository: theacodes/nox
Pull request #390 (2021-02-20 18:13:59)

Add Windows to GitHub Actions, remove AppVeyor


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e94ddb9..9d9d7b4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,7 +5,7 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        os: [ubuntu-20.04]
+        os: [ubuntu-20.04, windows-2019]
         python-version: [3.6, 3.7, 3.8, 3.9]
     steps:
       - uses: actions/checkout@v2
@@ -22,7 +22,7 @@ jobs:
         run: |
           python -m pip install --disable-pip-version-check .
       - name: Run tests on ${{ matrix.os }}
-        run: nox --non-interactive --session "tests-${{ matrix.python-version }}"
+        run: nox --non-interactive --session "tests-${{ matrix.python-version }}" -- --full-trace
   lint:
     runs-on: ubuntu-20.04
     steps:
diff --git a/appveyor.yml b/appveyor.yml
deleted file mode 100644
index d853541..0000000
--- a/appveyor.yml
+++ /dev/null
@@ -1,90 +0,0 @@
-version: 1.0.{build}.{branch}
-
-matrix:
-  fast_finish: true
-
-environment:
-  matrix:
-
-    # Pre-installed Python versions, which AppVeyor may upgrade to
-    # a later point release.
-    # See: http://www.appveyor.com/docs/installed-software#python
-
-    - PYTHON: "C:\\Python39"
-      # There is no miniconda for python3.9 at this time
-      CONDA: "C:\\Miniconda37"
-      NOX_SESSION: "tests-3.9"
-
-    - PYTHON: "C:\\Python39-x64"
-      # There is no miniconda for python3.9 at this time
-      CONDA: "C:\\Miniconda37-x64"
-      NOX_SESSION: "tests-3.9"
-
-    - PYTHON: "C:\\Python38"
-      # There is no miniconda for python3.8 at this time
-      CONDA: "C:\\Miniconda37"
-      NOX_SESSION: "tests-3.8"
-
-    - PYTHON: "C:\\Python38-x64"
-      # There is no miniconda for python3.8 at this time
-      CONDA: "C:\\Miniconda37-x64"
-      NOX_SESSION: "tests-3.8"
-
-    - PYTHON: "C:\\Python37"
-      CONDA: "C:\\Miniconda37"
-      NOX_SESSION: "tests-3.7"
-
-    - PYTHON: "C:\\Python37-x64"
-      CONDA: "C:\\Miniconda37-x64"
-      NOX_SESSION: "tests-3.7"
-
-    - PYTHON: "C:\\Python36"
-      CONDA: "C:\\Miniconda36"
-      NOX_SESSION: "tests-3.6"
-
-    - PYTHON: "C:\\Python36-x64"
-      CONDA: "C:\\Miniconda36-x64"
-      NOX_SESSION: "tests-3.6"
-
-
-install:
-  # Add conda command to path.
-  # https://www.tjelvarolsson.com/blog/how-to-continuously-test-your-python-code-on-windows-using-appveyor/
-  - "SET PATH=%CONDA%;%CONDA%\\Scripts;%PATH%"
-  - conda config --set changeps1 no
-  - conda update -q --yes conda
-  # Get Python from conda-forge.
-  - conda config --add channels conda-forge
-  - conda info -a
-
-  # Prepend newly installed Python to the PATH of this build (this cannot be
-  # done from inside the powershell script as it would require to restart
-  # the parent CMD process).
-  - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%"
-
-  # Check that we have the expected version and architecture for Python
-  - "python --version"
-  - "python -c \"import struct; print(struct.calcsize('P') * 8)\""
-
-  # Upgrade to the latest version of pip to avoid it displaying warnings
-  # about it being out of date.
-  - "python -m pip install --disable-pip-version-check --user --upgrade pip"
-
-  # Install the build dependencies of the project. If some dependencies contain
-  # compiled extensions and are not provided as pre-built wheel packages,
-  # pip will build them from source using the MSVC compiler matching the
-  # target Python version and architecture
-  - "python -m pip install wheel"
-
-# init:
-#   - ps: iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1'))
-
-# on_finish:
-#   - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1'))
-
-build_script:
-  - "python -m pip install ."
-
-test_script:
-  # Run the project tests
-  - "nox.exe --session \"%NOX_SESSION%\""
diff --git a/docs/config.rst b/docs/config.rst
index 99580d1..a8da73d 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 7539203..78c5283 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 2f551cc..d6043ac 100644
--- a/nox/__main__.py
+++ b/nox/__main__.py
@@ -22,13 +22,9 @@ control to :meth:``nox.workflow.execute``.
 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 0000000..296f2e6
--- /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 = "<unknown>") -> 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 31d4c82..c8d99b0 100644
--- a/nox/tasks.py
+++ b/nox/tasks.py
@@ -23,6 +23,7 @@ from typing import List, Union
 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/noxfile.py b/noxfile.py
index 063e36c..5eb89ae 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -14,10 +14,11 @@
 
 import functools
 import os
+import platform
 
 import nox
 
-ON_APPVEYOR = os.environ.get("APPVEYOR") == "True"
+ON_WINDOWS_CI = "CI" in os.environ and platform.system() == "Windows"
 
 
 def is_python_version(session, version):
@@ -60,12 +61,11 @@ def conda_tests(session):
 @nox.session
 def cover(session):
     """Coverage analysis."""
+    if ON_WINDOWS_CI:
+        return
+
     session.install("coverage")
-    if ON_APPVEYOR:
-        fail_under = "--fail-under=99"
-    else:
-        fail_under = "--fail-under=100"
-    session.run("coverage", "report", fail_under, "--show-missing")
+    session.run("coverage", "report", "--fail-under=100", "--show-missing")
     session.run("coverage", "erase")
 
 
diff --git a/setup.py b/setup.py
index cd8d73a..c8de26b 100644
--- a/setup.py
+++ b/setup.py
@@ -52,6 +52,7 @@ setup(
     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'",


Test patch:
diff --git a/tests/test__version.py b/tests/test__version.py
new file mode 100644
index 0000000..2606952
--- /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 88c5fc4..65f3112 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -18,6 +18,7 @@ import io
 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