diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 79e701b84..e43343d3e 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -1 +1,5 @@ # Unreleased + +## ✨ Added + +* #233: Added nox task to verify dependency declarations \ No newline at end of file diff --git a/doc/user_guide/getting_started.rst b/doc/user_guide/getting_started.rst index e126d96b6..966997b4c 100644 --- a/doc/user_guide/getting_started.rst +++ b/doc/user_guide/getting_started.rst @@ -193,6 +193,7 @@ You are ready to use the toolbox. With *nox -l* you can list all available tasks - lint:code -> Runs the static code analyzer on the project - lint:typing -> Runs the type checker on the project - lint:security -> Runs the security linter on the project + - lint:dependencies -> Checks if only valid sources of dependencies are used - docs:multiversion -> Builds the multiversion project documentation - docs:build -> Builds the project documentation - docs:open -> Opens the built project documentation diff --git a/exasol/toolbox/nox/_lint.py b/exasol/toolbox/nox/_lint.py index ab5f87e2c..5ca637448 100644 --- a/exasol/toolbox/nox/_lint.py +++ b/exasol/toolbox/nox/_lint.py @@ -1,6 +1,10 @@ from __future__ import annotations -from typing import Iterable +from typing import ( + Iterable, + List, + Dict +) import nox from nox import Session @@ -8,6 +12,11 @@ from exasol.toolbox.nox._shared import python_files from noxconfig import PROJECT_CONFIG +from pathlib import Path +import rich.console +import tomlkit +import sys + def _pylint(session: Session, files: Iterable[str]) -> None: session.run( @@ -65,6 +74,61 @@ def _security_lint(session: Session, files: Iterable[str]) -> None: ) +class Dependencies: + def __init__(self, illegal: Dict[str, List[str]] | None): + self._illegal = illegal or {} + + @staticmethod + def parse(pyproject_toml: str) -> "Dependencies": + def _source_filter(version) -> bool: + ILLEGAL_SPECIFIERS = ['url', 'git', 'path'] + return any( + specifier in version + for specifier in ILLEGAL_SPECIFIERS + ) + + def find_illegal(part) -> List[str]: + return [ + f"{name} = {version}" + for name, version in part.items() + if _source_filter(version) + ] + + illegal: Dict[str, List[str]] = {} + toml = tomlkit.loads(pyproject_toml) + poetry = toml.get("tool", {}).get("poetry", {}) + + part = poetry.get("dependencies", {}) + if illegal_group := find_illegal(part): + illegal["tool.poetry.dependencies"] = illegal_group + + part = poetry.get("dev", {}).get("dependencies", {}) + if illegal_group := find_illegal(part): + illegal["tool.poetry.dev.dependencies"] = illegal_group + + part = poetry.get("group", {}) + for group, content in part.items(): + illegal_group = find_illegal(content.get("dependencies", {})) + if illegal_group: + illegal[f"tool.poetry.group.{group}.dependencies"] = illegal_group + return Dependencies(illegal) + + @property + def illegal(self) -> Dict[str, List[str]]: + return self._illegal + + +def report_illegal(illegal: Dict[str, List[str]], console: rich.console.Console): + count = sum(len(deps) for deps in illegal.values()) + suffix = "y" if count == 1 else "ies" + console.print(f"{count} illegal dependenc{suffix}\n", style="red") + for section, dependencies in illegal.items(): + console.print(f"\\[{section}]", style="red") + for dependency in dependencies: + console.print(dependency, style="red") + console.print("") + + @nox.session(name="lint:code", python=False) def lint(session: Session) -> None: "Runs the static code analyzer on the project" @@ -84,3 +148,14 @@ def security_lint(session: Session) -> None: """Runs the security linter on the project""" py_files = [f"{file}" for file in python_files(PROJECT_CONFIG.root)] _security_lint(session, list(filter(lambda file: "test" not in file, py_files))) + + +@nox.session(name="lint:dependencies", python=False) +def dependency_check(session: Session) -> None: + """Checks if only valid sources of dependencies are used""" + content = Path(PROJECT_CONFIG.root, "pyproject.toml").read_text() + dependencies = Dependencies.parse(content) + console = rich.console.Console() + if illegal := dependencies.illegal: + report_illegal(illegal, console) + sys.exit(1) \ No newline at end of file diff --git a/exasol/toolbox/nox/tasks.py b/exasol/toolbox/nox/tasks.py index 39e96d085..3504b5e57 100644 --- a/exasol/toolbox/nox/tasks.py +++ b/exasol/toolbox/nox/tasks.py @@ -68,5 +68,6 @@ def check(session: Session) -> None: python_files, ) + # isort: on # fmt: on diff --git a/test/unit/dependencies_check_test.py b/test/unit/dependencies_check_test.py new file mode 100644 index 000000000..030e66cc9 --- /dev/null +++ b/test/unit/dependencies_check_test.py @@ -0,0 +1,147 @@ +import pytest +import rich.console + +from exasol.toolbox.nox._lint import Dependencies, report_illegal + + +@pytest.mark.parametrize( + "toml,expected", + [ + ( + """ + """, + {} + ), + ( + """ +[tool.poetry.dependencies] +python = "^3.8" +example-url1 = {url = "https://example.com/my-package-0.1.0.tar.gz"} + +[tool.poetry.dev.dependencies] +nox = ">=2022.8.7" +example-url2 = {url = "https://example.com/my-package-0.2.0.tar.gz"} + +[tool.poetry.group.test.dependencies] +sphinx = ">=5.3,<8" +example-git = {git = "git@github.com:requests/requests.git"} + +[tool.poetry.group.dev.dependencies] +pytest = ">=7.2.2,<9" +example-path1 = {path = "../my-package/dist/my-package-0.1.0.tar.gz"} + """, + { + "tool.poetry.dependencies": ["example-url1 = {'url': 'https://example.com/my-package-0.1.0.tar.gz'}"], + "tool.poetry.dev.dependencies": ["example-url2 = {'url': 'https://example.com/my-package-0.2.0.tar.gz'}"], + "tool.poetry.group.test.dependencies": ["example-git = {'git': 'git@github.com:requests/requests.git'}"], + "tool.poetry.group.dev.dependencies": ["example-path1 = {'path': '../my-package/dist/my-package-0.1.0.tar.gz'}"], + } + ), + ( + """ +[tool.poetry.dev.dependencies] +nox = ">=2022.8.7" +example-url2 = {url = "https://example.com/my-package-0.2.0.tar.gz"} + +[tool.poetry.group.test.dependencies] +sphinx = ">=5.3,<8" +example-git = {git = "git@github.com:requests/requests.git"} + +[tool.poetry.group.dev.dependencies] +pytest = ">=7.2.2,<9" +example-path1 = {path = "../my-package/dist/my-package-0.1.0.tar.gz"} + """, + { + "tool.poetry.dev.dependencies": ["example-url2 = {'url': 'https://example.com/my-package-0.2.0.tar.gz'}"], + "tool.poetry.group.test.dependencies": ["example-git = {'git': 'git@github.com:requests/requests.git'}"], + "tool.poetry.group.dev.dependencies": ["example-path1 = {'path': '../my-package/dist/my-package-0.1.0.tar.gz'}"], + } + ), + ( + """ +[tool.poetry.dependencies] +python = "^3.8" +example-url1 = {url = "https://example.com/my-package-0.1.0.tar.gz"} + +[tool.poetry.group.test.dependencies] +sphinx = ">=5.3,<8" +example-git = {git = "git@github.com:requests/requests.git"} + +[tool.poetry.group.dev.dependencies] +pytest = ">=7.2.2,<9" +example-path1 = {path = "../my-package/dist/my-package-0.1.0.tar.gz"} + """, + { + "tool.poetry.dependencies": ["example-url1 = {'url': 'https://example.com/my-package-0.1.0.tar.gz'}"], + "tool.poetry.group.test.dependencies": ["example-git = {'git': 'git@github.com:requests/requests.git'}"], + "tool.poetry.group.dev.dependencies": ["example-path1 = {'path': '../my-package/dist/my-package-0.1.0.tar.gz'}"], + } + ), + ( + """ +[tool.poetry.dependencies] +python = "^3.8" +example-url1 = {url = "https://example.com/my-package-0.1.0.tar.gz"} + +[tool.poetry.dev.dependencies] +nox = ">=2022.8.7" +example-url2 = {url = "https://example.com/my-package-0.2.0.tar.gz"} + """, + { + "tool.poetry.dependencies": ["example-url1 = {'url': 'https://example.com/my-package-0.1.0.tar.gz'}"], + "tool.poetry.dev.dependencies": ["example-url2 = {'url': 'https://example.com/my-package-0.2.0.tar.gz'}"], + } + ) + ] +) +def test_dependency_check_parse(toml, expected): + dependencies = dependencies = Dependencies.parse(toml) + assert dependencies.illegal == expected + + +@pytest.mark.parametrize( + "toml,expected", + [ + ( + """ +[tool.poetry.dependencies] +python = "^3.8" +example-url1 = {url = "https://example.com/my-package-0.1.0.tar.gz"} + +[tool.poetry.dev.dependencies] +nox = ">=2022.8.7" +example-url2 = {url = "https://example.com/my-package-0.2.0.tar.gz"} + +[tool.poetry.group.test.dependencies] +sphinx = ">=5.3,<8" +example-git = {git = "git@github.com:requests/requests.git"} + +[tool.poetry.group.dev.dependencies] +pytest = ">=7.2.2,<9" +example-path1 = {path = "../my-package/dist/my-package-0.1.0.tar.gz"} +example-path2 = {path = "../my-package/dist/my-package-0.2.0.tar.gz"} + """, + """5 illegal dependencies + +[tool.poetry.dependencies] +example-url1 = {'url': 'https://example.com/my-package-0.1.0.tar.gz'} + +[tool.poetry.dev.dependencies] +example-url2 = {'url': 'https://example.com/my-package-0.2.0.tar.gz'} + +[tool.poetry.group.test.dependencies] +example-git = {'git': 'git@github.com:requests/requests.git'} + +[tool.poetry.group.dev.dependencies] +example-path1 = {'path': '../my-package/dist/my-package-0.1.0.tar.gz'} +example-path2 = {'path': '../my-package/dist/my-package-0.2.0.tar.gz'} + +""" + ), + ] +) +def test_dependencies_check_report(toml, expected, capsys): + console = rich.console.Console() + dependencies = Dependencies.parse(toml) + report_illegal(dependencies.illegal, console) + assert capsys.readouterr().out == expected