diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index f37e6a89e..825322dee 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -31,9 +31,12 @@ jobs: working-directory: ./artifacts run: | poetry run coverage combine --keep coverage-python3.9*/.coverage - cp .coverage ../ - cp lint-python3.9/.lint.txt ../ - cp security-python3.9/.security.json ../ + cp .coverage ../ || true + cp lint-python3.9/.lint.txt ../ || true + cp security-python3.9/.security.json ../ || true + + - name: Validate Artifacts + run: poetry run nox -s artifacts:validate - name: Generate Report run: poetry run nox -s project:report -- -- --format json | tee metrics.json diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 7b46e8365..8311d0e44 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -30,8 +30,10 @@ * `matrix-exasol.yml` Among all of the above, the safest way is to set the matrix-related fields in your project config object in `noxconfig.py`. - - + +* Added a nox task to validate the build/test artifacts and use it in the github workflow report + + ## 📚 Documentation * Added new entries to the frequently asked questions regarding `multiversion documentation` @@ -39,5 +41,6 @@ ## 🐞 Fixed +* Fixed `index.rst` documentation template to provide the correct underlining length of the main heading * Added multi-version extension to Sphinx configuration of the project template * fixed bug in tbx worflow install error if directory exists[#298](https://github.com/exasol/python-toolbox/issues/298) also [#297](https://github.com/exasol/python-toolbox/issues/297) \ No newline at end of file diff --git a/exasol/toolbox/nox/_artifacts.py b/exasol/toolbox/nox/_artifacts.py new file mode 100644 index 000000000..c120e5b30 --- /dev/null +++ b/exasol/toolbox/nox/_artifacts.py @@ -0,0 +1,118 @@ +import json +import pathlib +import re +import sqlite3 +import sys +from pathlib import Path + +import nox +from nox import Session + +from noxconfig import PROJECT_CONFIG + + +@nox.session(name="artifacts:validate", python=False) +def check_artifacts(session: Session) -> None: + """Validate that all project artifacts are available and consistent""" + if not_available := _missing_files( + {".lint.txt", ".security.json", ".coverage"}, PROJECT_CONFIG.root + ): + print(f"not available: {not_available}") + sys.exit(1) + + error = False + if msg := _validate_lint_txt(Path(PROJECT_CONFIG.root, ".lint.txt")): + print(f"error in [.lint.txt]: {msg}") + if msg := _validate_security_json(Path(PROJECT_CONFIG.root, ".security.json")): + print(f"error in [.security.json]: {msg}") + error = True + if msg := _validate_coverage(Path(PROJECT_CONFIG.root, ".coverage")): + print(f"error in [.coverage]: {msg}") + error = True + if error: + sys.exit(1) + + +def _missing_files(expected_files: set, directory: Path) -> set: + files = {f.name for f in directory.iterdir() if f.is_file()} + return expected_files - files + + +def _validate_lint_txt(file: Path) -> str: + try: + content = file.read_text() + except FileNotFoundError as ex: + return f"Could not find file {file}, details: {ex}" + expr = re.compile(r"^Your code has been rated at (\d+.\d+)/.*", re.MULTILINE) + matches = expr.search(content) + if not matches: + return f"Could not find a rating" + return "" + + +def _validate_lint_json(file: Path) -> str: + try: + content = file.read_text() + except FileNotFoundError as ex: + return f"Could not find file {file}, details: {ex}" + try: + issues = json.loads(content) + except json.JSONDecodeError as ex: + return f"Invalid json file, details: {ex}" + expected = { + "type", + "module", + "obj", + "line", + "column", + "endLine", + "endColumn", + "path", + "symbol", + "message", + "message-id", + } + for number, issue in enumerate(issues): + actual = set(issue.keys()) + missing = expected - actual + if len(missing) > 0: + return f"Invalid format, issue {number} is missing the following attributes {missing}" + return "" + + +def _validate_security_json(file: Path) -> str: + try: + content = file.read_text() + except FileNotFoundError as ex: + return f"Could not find file {file}, details: {ex}" + try: + actual = set(json.loads(content)) + except json.JSONDecodeError as ex: + return f"Invalid json file, details: {ex}" + expected = {"errors", "generated_at", "metrics", "results"} + missing = expected - actual + if len(missing) > 0: + return f"Invalid format, the file is missing the following attributes {missing}" + return "" + + +def _validate_coverage(path: Path) -> str: + try: + conn = sqlite3.connect(path) + except sqlite3.Error as ex: + return f"database connection not possible, details: {ex}" + cursor = conn.cursor() + try: + actual_tables = set( + cursor.execute("select name from sqlite_schema where type == 'table'") + ) + except sqlite3.Error as ex: + return f"schema query not possible, details: {ex}" + expected = {"coverage_schema", "meta", "file", "line_bits"} + actual = {f[0] for f in actual_tables if (f[0] in expected)} + missing = expected - actual + if len(missing) > 0: + return ( + f"Invalid database, the database is missing the following tables {missing}" + ) + return "" diff --git a/exasol/toolbox/nox/tasks.py b/exasol/toolbox/nox/tasks.py index 99fe598df..1c59f7c2c 100644 --- a/exasol/toolbox/nox/tasks.py +++ b/exasol/toolbox/nox/tasks.py @@ -59,6 +59,7 @@ def check(session: Session) -> None: clean_docs, open_docs, ) +from exasol.toolbox.nox._release import prepare_release from exasol.toolbox.nox._shared import ( Mode, _context, @@ -74,5 +75,8 @@ def check(session: Session) -> None: from exasol.toolbox.nox._release import prepare_release +from exasol.toolbox.nox._artifacts import ( + check_artifacts +) # isort: on # fmt: on diff --git a/poetry.lock b/poetry.lock index a5028593d..275122718 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1148,13 +1148,13 @@ pytest-plugin = ["pytest-prysk (>=0.2.0,<0.3.0)"] [[package]] name = "pydantic" -version = "2.10.2" +version = "2.10.3" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.10.2-py3-none-any.whl", hash = "sha256:cfb96e45951117c3024e6b67b25cdc33a3cb7b2fa62e239f7af1378358a1d99e"}, - {file = "pydantic-2.10.2.tar.gz", hash = "sha256:2bc2d7f17232e0841cbba4641e65ba1eb6fafb3a08de3a091ff3ce14a197c4fa"}, + {file = "pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d"}, + {file = "pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9"}, ] [package.dependencies] @@ -1305,17 +1305,17 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pylint" -version = "3.3.1" +version = "3.3.2" description = "python code static checker" optional = false python-versions = ">=3.9.0" files = [ - {file = "pylint-3.3.1-py3-none-any.whl", hash = "sha256:2f846a466dd023513240bc140ad2dd73bfc080a5d85a710afdb728c420a5a2b9"}, - {file = "pylint-3.3.1.tar.gz", hash = "sha256:9f3dcc87b1203e612b78d91a896407787e708b3f189b5fa0b307712d49ff0c6e"}, + {file = "pylint-3.3.2-py3-none-any.whl", hash = "sha256:77f068c287d49b8683cd7c6e624243c74f92890f767f106ffa1ddf3c0a54cb7a"}, + {file = "pylint-3.3.2.tar.gz", hash = "sha256:9ec054ec992cd05ad30a6df1676229739a73f8feeabf3912c995d17601052b01"}, ] [package.dependencies] -astroid = ">=3.3.4,<=3.4.0-dev0" +astroid = ">=3.3.5,<=3.4.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, @@ -1856,13 +1856,13 @@ files = [ [[package]] name = "typer" -version = "0.14.0" +version = "0.15.1" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.7" files = [ - {file = "typer-0.14.0-py3-none-any.whl", hash = "sha256:f476233a25770ab3e7b2eebf7c68f3bc702031681a008b20167573a4b7018f09"}, - {file = "typer-0.14.0.tar.gz", hash = "sha256:af58f737f8d0c0c37b9f955a6d39000b9ff97813afcbeef56af5e37cf743b45a"}, + {file = "typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847"}, + {file = "typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a"}, ] [package.dependencies] diff --git a/project-template/{{cookiecutter.repo_name}}/doc/index.rst b/project-template/{{cookiecutter.repo_name}}/doc/index.rst index f41dbcef0..e224a7558 100644 --- a/project-template/{{cookiecutter.repo_name}}/doc/index.rst +++ b/project-template/{{cookiecutter.repo_name}}/doc/index.rst @@ -1,5 +1,5 @@ Documentation of {{cookiecutter.project_name}} -------------------{{ '-' * cookiecutter.package_name | length }} +------------------{{ '-' * cookiecutter.project_name | length }} .. grid:: 1 1 3 2 :gutter: 2 @@ -33,4 +33,3 @@ Documentation of {{cookiecutter.project_name}} api faq changes/changelog - diff --git a/test/unit/lint_file_check_test.py b/test/unit/lint_file_check_test.py new file mode 100644 index 000000000..a9e4aa494 --- /dev/null +++ b/test/unit/lint_file_check_test.py @@ -0,0 +1,339 @@ +import json +import sqlite3 +from pathlib import Path + +import pytest + +from exasol.toolbox.nox import _artifacts + + +@pytest.mark.parametrize( + "files,requested_files,expected", + [ + ( + {".lint.json", ".lint.txt", ".security.json", ".coverage"}, + {".lint.json", ".lint.txt", ".security.json", ".coverage"}, + set(), + ), + ( + {".lint.txt", ".security.json", ".coverage"}, + {".lint.json", ".lint.txt", ".security.json", ".coverage"}, + {".lint.json"}, + ), + ( + {".lint.json", ".security.json", ".coverage"}, + {".lint.json", ".lint.txt", ".security.json", ".coverage"}, + {".lint.txt"}, + ), + ( + {".lint.json", ".lint.txt", ".coverage"}, + {".lint.json", ".lint.txt", ".security.json", ".coverage"}, + {".security.json"}, + ), + ( + {".lint.json", ".lint.txt", ".security.json"}, + {".lint.json", ".lint.txt", ".security.json", ".coverage"}, + {".coverage"}, + ), + ( + {","}, + {".lint.json", ".lint.txt", ".security.json", ".coverage"}, + {".lint.json", ".lint.txt", ".security.json", ".coverage"}, + ), + ], +) +def test_check_lint_files(files, requested_files, expected, tmp_path): + path = Path(tmp_path) + for file in files: + Path(path, file).touch() + + actual = _artifacts._missing_files(requested_files, path) + assert actual == expected + + +@pytest.mark.parametrize( + "file,expected", + [ + ("Your code has been rated at 7.85/10 (previous run: 7.83/10, +0.02", ""), + ( + "test_text\nYour code has been rated at 7.85/10 (previous run: 7.83/10, +0.02\ntest_text", + "", + ), + ("", "Could not find a rating"), + ("test_text", "Could not find a rating"), + ], +) +def test_check_lint_txt(file, expected, tmp_path): + path = Path(tmp_path, ".lint.txt") + path.touch() + path.write_text(file) + actual = _artifacts._validate_lint_txt(path) + assert actual == expected + + +@pytest.mark.parametrize( + "attributes,expected", + [ + ( + [ + "type", + "module", + "obj", + "line", + "column", + "endLine", + "endColumn", + "path", + "symbol", + "message", + "message-id", + ], + "", + ), + ( + [ + "module", + "obj", + "line", + "column", + "endLine", + "endColumn", + "path", + "symbol", + "message", + "message-id", + ], + "Invalid format, issue 0 is missing the following attributes {'type'}", + ), + ( + [ + "type", + "obj", + "line", + "column", + "endLine", + "endColumn", + "path", + "symbol", + "message", + "message-id", + ], + "Invalid format, issue 0 is missing the following attributes {'module'}", + ), + ( + [ + "type", + "module", + "line", + "column", + "endLine", + "endColumn", + "path", + "symbol", + "message", + "message-id", + ], + "Invalid format, issue 0 is missing the following attributes {'obj'}", + ), + ( + [ + "type", + "module", + "obj", + "column", + "endLine", + "endColumn", + "path", + "symbol", + "message", + "message-id", + ], + "Invalid format, issue 0 is missing the following attributes {'line'}", + ), + ( + [ + "type", + "module", + "obj", + "line", + "endLine", + "endColumn", + "path", + "symbol", + "message", + "message-id", + ], + "Invalid format, issue 0 is missing the following attributes {'column'}", + ), + ( + [ + "type", + "module", + "obj", + "line", + "column", + "endColumn", + "path", + "symbol", + "message", + "message-id", + ], + "Invalid format, issue 0 is missing the following attributes {'endLine'}", + ), + ( + [ + "type", + "module", + "obj", + "line", + "column", + "endLine", + "path", + "symbol", + "message", + "message-id", + ], + "Invalid format, issue 0 is missing the following attributes {'endColumn'}", + ), + ( + [ + "type", + "module", + "obj", + "line", + "column", + "endLine", + "endColumn", + "symbol", + "message", + "message-id", + ], + "Invalid format, issue 0 is missing the following attributes {'path'}", + ), + ( + [ + "type", + "module", + "obj", + "line", + "column", + "endLine", + "endColumn", + "path", + "message", + "message-id", + ], + "Invalid format, issue 0 is missing the following attributes {'symbol'}", + ), + ( + [ + "type", + "module", + "obj", + "line", + "column", + "endLine", + "endColumn", + "path", + "symbol", + "message-id", + ], + "Invalid format, issue 0 is missing the following attributes {'message'}", + ), + ( + [ + "type", + "module", + "obj", + "line", + "column", + "endLine", + "endColumn", + "path", + "symbol", + "message", + ], + "Invalid format, issue 0 is missing the following attributes {'message-id'}", + ), + ], +) +def test_check_lint_json(attributes, expected, tmp_path): + path = Path(tmp_path, ".lint.json") + path.touch() + attributes_dict = {} + for attribute in attributes: + attributes_dict[attribute] = None + with path.open("w") as file: + json.dump([attributes_dict], file) + actual = _artifacts._validate_lint_json(path) + assert actual == expected + + +@pytest.mark.parametrize( + "attributes,expected", + [ + (["errors", "generated_at", "metrics", "results"], ""), + ( + ["generated_at", "metrics", "results"], + "Invalid format, the file is missing the following attributes {'errors'}", + ), + ( + ["errors", "metrics", "results"], + "Invalid format, the file is missing the following attributes {'generated_at'}", + ), + ( + ["errors", "generated_at", "results"], + "Invalid format, the file is missing the following attributes {'metrics'}", + ), + ( + ["errors", "generated_at", "metrics"], + "Invalid format, the file is missing the following attributes {'results'}", + ), + ], +) +def test_check_security_json(attributes, expected, tmp_path): + path = Path(tmp_path, ".security.json") + path.touch() + attributes_dict = {} + for attribute in attributes: + attributes_dict[attribute] = None + with path.open("w") as file: + json.dump(attributes_dict, file) + actual = _artifacts._validate_security_json(path) + assert actual == expected + + +@pytest.mark.parametrize( + "tables, expected", + [ + (["coverage_schema", "meta", "file", "line_bits"], ""), + ( + ["meta", "file", "line_bits"], + "Invalid database, the database is missing the following tables {'coverage_schema'}", + ), + ( + ["coverage_schema", "file", "line_bits"], + "Invalid database, the database is missing the following tables {'meta'}", + ), + ( + ["coverage_schema", "meta", "line_bits"], + "Invalid database, the database is missing the following tables {'file'}", + ), + ( + [ + "coverage_schema", + "meta", + "file", + ], + "Invalid database, the database is missing the following tables {'line_bits'}", + ), + ], +) +def test_check_coverage(tables, expected, tmp_path): + path = Path(tmp_path, ".coverage") + connection = sqlite3.connect(path) + cursor = connection.cursor() + for table in tables: + cursor.execute(f"CREATE TABLE IF NOT EXISTS {table} (test INTEGER)") + actual = _artifacts._validate_coverage(path) + assert actual == expected