Repository: theacodes/nox (python)

Issue:
Add `needs_nox` parsing to specify minimum version
The current switch from `0.19.1` to `>= 2018` will probably cause some breakage and it may be useful to specify

```python
needs_nox = "2018.7.31"
```

much in the same way Sphinx supports a

```python
needs_sphinx = "1.0"
```

Pull request description:
Add nox.needs_version to specify Nox version requirements
Exit with a friendly error if the Nox version does not satisfy the requirements
specified by the user's Noxfile in `nox.needs_version`:

- Add dependency on [packaging] to Nox `install_requires`
- Add `nox.needs_version` attribute with [PEP 440] version specifiers for Nox
- Add `nox._version` module with these functions and classes:
  - function `get_nox_version`
  - function `check_nox_version`
  - exception `VersionCheckFailed`
  - exception `InvalidVersionSpecifier`
- Adapt `tasks.load_nox_module` to invoke `check_nox_version`
- Refactor `__main__` to use `get_nox_version` for `--version` option
- Tests and documentation

Closes #99
Supercedes #210

Some words about the approach taken here:

1. Rather than a minimum required version, `needs_version` accepts any [PEP 440]
   version specifier. Mostly, this just seemed a better conceptual match. If
   users specify a version `V`, Nox will suggest to use `>=V` instead.
   
   Version specifiers are also a much more expressive device. For example, a
   Noxfile could specify an upper bound if it has not been adapted to a breaking
   change in a newer Nox version. It could exclude a version with a bug that it
   was affected by. Nox, on its part, could offer a compatibility interface for
   older Noxfiles, should a large breaking change to the Nox API ever become
   necessary.
   
2. Version requirements are checked in two passes. In the first pass, Nox
   attempts to determine `nox.needs_version` from the [AST], using a best effort
   approach; if found, Nox checks the version a first time. The second pass
   occurs after the Noxfile was imported, and checks the version against
   `nox.needs_version` directly. The rationale is that we should avoid executing
   user code that wasn't written for our version. While parsing the AST should
   cover the majority of uses, the AST parser is kept deliberately simple;
   anything more complex than assigning a string literal is left for the second
   pass. A regular expression could have covered most of these use cases as
   well; however, parsing the AST seemed a better fit and not overly complex.

3. The implementation uses [packaging] instead of the [deprecated distutils
   module][PEP 632]. `LooseVersion` has been removed from the packaging API, and
   should not be needed anyway because, as a PyPI package, Nox versions conform
   to [PEP 440].

[AST]: https://docs.python.org/3/library/ast.html
[PEP 440]: https://www.python.org/dev/peps/pep-0440/
[PEP 632]: https://www.python.org/dev/peps/pep-0632/
[packaging]: https://packaging.pypa.io/en/stable/


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e94ddb9
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,69 @@
+name: CI
+on: [push, pull_request]
+jobs:
+  build:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ubuntu-20.04]
+        python-version: [3.6, 3.7, 3.8, 3.9]
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Set up Miniconda
+        uses: conda-incubator/setup-miniconda@v2
+        with:
+          auto-update-conda: true
+          python-version: ${{ matrix.python-version }}
+      - 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-${{ matrix.python-version }}"
+  lint:
+    runs-on: ubuntu-20.04
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python 3.8
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.8
+      - name: Install Nox-under-test
+        run: |
+          python -m pip install --disable-pip-version-check .
+      - name: Lint
+        run: nox --non-interactive --session "lint"
+  docs:
+    runs-on: ubuntu-20.04
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python 3.8
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.8
+      - name: Install Nox-under-test
+        run: |
+          python -m pip install --disable-pip-version-check .
+      - name: Docs
+        run: nox --non-interactive --session "docs"
+  deploy:
+    needs: build
+    runs-on: ubuntu-20.04
+    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
+      - name: Publish distribution PyPI
+        uses: pypa/gh-action-pypi-publish@master
+        with:
+          password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 34f9411..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-language: python
-dist: xenial
-matrix:
-  include:
-  - python: '3.6'
-    env: NOXSESSION="tests-3.6"
-  - python: '3.7'
-    env: NOXSESSION="tests-3.7"
-  - python: '3.8'
-    env: NOXSESSION="tests-3.8"
-  - python: '3.9'
-    env: NOXSESSION="tests-3.9"
-  - python: '3.8'
-    env: NOXSESSION="lint"
-  - python: '3.8'
-    env: NOXSESSION="docs"
-before_install:
-  - wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh
-  - chmod +x miniconda.sh
-  - ./miniconda.sh -b
-  # Prefer the built-in python binary.
-  - export PATH="$PATH:/home/travis/miniconda3/bin"
-  - conda update --yes conda
-  - export SETUPTOOLS_USE_DISTUTILS=stdlib
-install:
-  - python -m pip install --upgrade pip setuptools
-  - python -m pip install .
-script: nox --non-interactive --session "$NOXSESSION"
-deploy:
-  provider: pypi
-  user: theacodes
-  password:
-    secure: ETRTnYg+8cilT0/HidhyPljERgE/u0boKdH9TW+JrY0De40Km5C+TUmPagKJuwPx1Gw8HNN1vN7M1pqaQ/flQaY9iNbuJZr5ZaApiZW1pw5/nO2wWoANx0hiChdjvwbJZdqUFEoba6MS9aBY7TroFlLjW6dUg8MZFSiUFRQDF9rTCyzB/juC7wiLTgrjlFpOvaOmf1qpVOajY5kfn8MLELms8itRUa04X4kqqgtOfifoA1CevObrScGSXlpPtqmoxUrCmwbnHu9qnqgAWLHe3y7fI4ZqscYQv/JCW8NdJgqMTn0jctLXibHt5vC/DtUYo47mFSRBfn55ZwAFiV6IiwVbtDKby0ZdNO2uIFn4B/7l0qrLTwnZbRy4vkPqEeJoS75vl4JQrauGmI+hgdtesHjZxLzs94H4vINVt0fGpkYqbgtMQO8HUQnnj0FJXcGKo4A5OuLjnk5+rgTSvLT/5qNg/cyve5BXkn1ib6ecah21MHSQyhl5CxIFBH6S6BRrGoxXluLqXPVv/w+QA0lxXCpAPfbHuMt4r9522YN/XfGQNHfNqK/836UbLEX5ZXZiZLl01IvVPl+3eC/Qmpc+tNXb51d53Qsm89VtaNGGvuV2eLPBR+gfXcQ8wFB1HW3Q3oshHCGW4KKApyzyYKWq27JPlGV13Yh+NMHWs9PGHyI=
-  on:
-    tags: true
-    distributions: sdist bdist_wheel
-    repo: theacodes/nox
-    condition: "$NOXSESSION = \"tests-3.6\""
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/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