Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add nox.needs_version to specify Nox version requirements #388

Merged
merged 18 commits into from
Feb 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious what the performance impact is of parsing the AST for this twice every time.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious what the performance impact is of parsing the AST for this twice every time.

Right, thanks for mentioning this, I had wanted to check that. I used cProfile
to measure performance for the Noxfiles of Nox (116 lines) and pip (334 lines).

These are the results for Nox:

❯ python -m cProfile -s cumtime -m nox -l | grep -E 'ncalls|builtins.exec|tasks.py|ast.py'
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     83/1    0.000    0.000    0.068    0.068 {built-in method builtins.exec}
        1    0.000    0.000    0.053    0.053 tasks.py:15(<module>)
        1    0.000    0.000    0.001    0.001 tasks.py:32(load_nox_module)
        1    0.000    0.000    0.001    0.001 ast.py:33(parse)
        1    0.000    0.000    0.000    0.000 tasks.py:94(discover_manifest)
        1    0.000    0.000    0.000    0.000 tasks.py:156(honor_list_request)
        1    0.000    0.000    0.000    0.000 tasks.py:80(merge_noxfile_options)
        1    0.000    0.000    0.000    0.000 tasks.py:115(filter_manifest)

These are the results for pip:

❯ python -m cProfile -s cumtime -m nox -l | grep -E 'ncalls|builtins.exec|tasks.py|ast.py'
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     88/1    0.000    0.000    0.071    0.071 {built-in method builtins.exec}
        1    0.000    0.000    0.052    0.052 tasks.py:15(<module>)
        1    0.000    0.000    0.006    0.006 tasks.py:32(load_nox_module)
        1    0.000    0.000    0.002    0.002 ast.py:33(parse)
        1    0.000    0.000    0.000    0.000 tasks.py:94(discover_manifest)
        1    0.000    0.000    0.000    0.000 tasks.py:156(honor_list_request)
        1    0.000    0.000    0.000    0.000 tasks.py:80(merge_noxfile_options)
        1    0.000    0.000    0.000    0.000 tasks.py:115(filter_manifest)

So parsing the AST adds around 1-2 milliseconds to startup time. Given that it
only happens once (in addition to when the Noxfile is imported), this would be
imperceptible.


# 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