-
-
Notifications
You must be signed in to change notification settings - Fork 158
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add nox.needs_version to specify Nox version requirements (#388)
* 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
1 parent
d4cf547
commit 142092f
Showing
8 changed files
with
328 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) |
Oops, something went wrong.