Skip to content

Commit

Permalink
Add nox.needs_version to specify Nox version requirements (#388)
Browse files Browse the repository at this point in the history
* feat: Add nox.needs_version
* 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.

* Use admonition for version specification warning
* Use a fixture to take care of common noxfile creation code

Co-authored-by: Paulius Maruška <paulius.maruska@gmail.com>
Co-authored-by: Thea Flowers <me@thea.codes>
  • Loading branch information
3 people authored Feb 20, 2021
1 parent d4cf547 commit 142092f
Show file tree
Hide file tree
Showing 8 changed files with 328 additions and 8 deletions.
27 changes: 26 additions & 1 deletion docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,6 @@ Produces these sessions when running ``nox --list``:
* tests(mysql, new)
The session object
------------------

Expand Down Expand Up @@ -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/
6 changes: 5 additions & 1 deletion nox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
8 changes: 2 additions & 6 deletions nox/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(
Expand Down
113 changes: 113 additions & 0 deletions nox/_version.py
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 7 additions & 0 deletions nox/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
135 changes: 135 additions & 0 deletions tests/test__version.py
Original file line number Diff line number Diff line change
@@ -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))
Loading

0 comments on commit 142092f

Please sign in to comment.