diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 122991d..93e31c4 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -24,7 +24,12 @@ jobs: poetry-version: 1.4.0 - name: Poetry install run: poetry install + - name: Poetry build run: poetry build - - name: Poetry run tests + + - name: Run tests for old API run: poetry run pytest tests + + - name: Run tests for new API and parser + run: poetry run pytest test diff --git a/README.md b/README.md index cf7f1d0..57cd3d6 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # Error Reporting Python -This project contains a python library for describing Exasol error messages. -This library lets you define errors with a uniform set of attributes. -Furthermore, the error message is implemented to be parseable, +This project contains a python library for describing Exasol error messages. +This library lets you define errors with a uniform set of attributes. +Furthermore, the error message is implemented to be parseable, so that you can extract an error catalog from the code. - ## In a Nutshell + Create an error object: ```python -exa_error_obj = ExaError.message_builder('E-TEST-1')\ - .message("Not enough space on device {{device}}.")\ - .mitigation("Delete something from {{device}}.")\ - .mitigation("Create larger partition.")\ +exa_error_obj = ExaError.message_builder('E-TEST-1') + .message("Not enough space on device {{device}}.") + .mitigation("Delete something from {{device}}.") + .mitigation("Create larger partition.") .parameter("device", "/dev/sda1", "name of the device") ``` @@ -24,15 +24,40 @@ print(exa_error_obj) ``` Result: + ``` E-TEST-1: Not enough space on device '/dev/sda1'. Known mitigations: * Delete something from '/dev/sda1'. * Create larger partition. ``` - Check out the [user guide](doc/user_guide/user_guide.md) for more details. +## Tooling + +The `error-reporting-python` library comes with a command line tool (`ec`) which also can be invoked +by using its package/module entry point (`python -m exasol.error`). +For detailed information about the usage consider consulting the help `ec --help` or `python -m exasol.error --help`. + +### Parsing the error definitions in a python file(s) + +```shell +ec parse some-python-file.py +``` + +```shell +ec parse < some-python-file.py +``` + +## Generating an error-code data file + +In order to generate a [error-code-report](https://schemas.exasol.com/error_code_report-1.0.0.json) compliant data file, +you can use the generate subcommand. + +```shell +ec generate NAME VERSION PACKAGE_ROOT > error-codes.json +``` + ### Information for Users * [User Guide](doc/user_guide/user_guide.md) diff --git a/doc/changes/changes_0.4.0.md b/doc/changes/changes_0.4.0.md index 2ee7965..4438706 100644 --- a/doc/changes/changes_0.4.0.md +++ b/doc/changes/changes_0.4.0.md @@ -6,6 +6,9 @@ Code Name: T.B.D T.B.D +## Feature + - #4: Add error parser/crawler cli tool + ### Refactoring - #19: Rework error reporting API diff --git a/exasol/error/__main__.py b/exasol/error/__main__.py new file mode 100644 index 0000000..8e5cbe2 --- /dev/null +++ b/exasol/error/__main__.py @@ -0,0 +1,4 @@ +from exasol.error._cli import main + +if __name__ == "__main__": + main() diff --git a/exasol/error/_cli.py b/exasol/error/_cli.py new file mode 100644 index 0000000..2704404 --- /dev/null +++ b/exasol/error/_cli.py @@ -0,0 +1,148 @@ +import argparse +import json +import sys +from enum import IntEnum +from itertools import chain +from pathlib import Path + +from exasol.error._parse import parse_file +from exasol.error._report import JsonEncoder + + +class ExitCode(IntEnum): + SUCCESS = 0 + FAILURE = -1 + + +def parse_command(args: argparse.Namespace) -> ExitCode: + """Parse errors out one or more python files and report them in the jsonl format.""" + for f in args.python_file: + definitions, warnings, errors = parse_file(f) + for d in definitions: + print(json.dumps(d, cls=JsonEncoder)) + for w in warnings: + print(w, file=sys.stderr) + if errors: + print("\n".join(str(e) for e in errors), file=sys.stderr) + return ExitCode.FAILURE + + return ExitCode.SUCCESS + + +def generate_command(args: argparse.Namespace) -> ExitCode: + """Generate an error code file for the specified workspace + + TODO: Improve command by reflecting information from pyproject.toml file + * Derive python files from pyproject.toml package definition + * Derive name form pyproject.toml + * Derive version from pyproject.toml + + see also [Github Issue #24](https://github.com/exasol/error-reporting-python/issues/24) + """ + + def _report(project_name, project_version, errors): + return { + "$schema": "https://schemas.exasol.com/error_code_report-1.0.0.json", + "projectName": project_name, + "projectVersion": project_version, + "errorCodes": [e for e in errors], + } + + all_definitions = list() + all_warnings = list() + paths = [Path(p) for p in args.root] + files = {f for f in chain.from_iterable([root.glob("**/*.py") for root in paths])} + for f in files: + definitions, warnings, errors = parse_file(f) + + if errors: + print("\n".join(str(e) for e in errors), file=sys.stderr) + return ExitCode.FAILURE + + all_definitions.extend(definitions) + all_warnings.extend(warnings) + + for w in all_warnings: + print(w, file=sys.stderr) + error_catalogue = _report(args.name, args.version, all_definitions) + print(json.dumps(error_catalogue, cls=JsonEncoder)) + + return ExitCode.SUCCESS + + +def _argument_parser(): + parser = argparse.ArgumentParser( + prog="ec", + description="Error Crawler", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--debug", action="store_true", help="Do not protect main entry point." + ) + subparsers = parser.add_subparsers() + + parse = subparsers.add_parser( + name="parse", + description="parses error definitions out of python files and reports them in jsonl format", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parse.add_argument( + "python_file", + metavar="python-file", + type=argparse.FileType("r"), + default=[sys.stdin], + nargs="*", + help="file to parse", + ) + parser.set_defaults(func=parse_command) + + generate = subparsers.add_parser( + name="generate", + description="Generate an error code report for a project.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + generate.add_argument( + "name", + metavar="name", + type=str, + help="of the project which will be in the report", + ) + generate.add_argument( + "version", + metavar="version", + type=str, + help="which shall be in the report. Should be in the following format 'MAJOR.MINOR.PATCH'", + ) + generate.add_argument( + "root", + metavar="root", + type=Path, + default=[Path(".")], + nargs="*", + help="to start recursively fetching python files from", + ) + generate.set_defaults(func=generate_command) + + return parser + + +def main(): + parser = _argument_parser() + args = parser.parse_args() + + def _unprotected(func, *args, **kwargs): + sys.exit(func(*args, **kwargs)) + + def _protected(func, *args, **kwargs): + try: + sys.exit(func(*args, **kwargs)) + except Exception as ex: + print( + f"Error occurred, details: {ex}. Try running with --debug to get more details." + ) + sys.exit(ExitCode.FAILURE) + + if args.debug: + _unprotected(args.func, args) + else: + _protected(args.func, args) diff --git a/exasol/error/_error.py b/exasol/error/_error.py index dbb27f3..c6c0960 100644 --- a/exasol/error/_error.py +++ b/exasol/error/_error.py @@ -1,7 +1,6 @@ import warnings -from collections.abc import Iterable, Mapping from dataclasses import dataclass -from typing import Dict, List, Union +from typing import Dict, Iterable, List, Mapping, Union with warnings.catch_warnings(): warnings.simplefilter("ignore") @@ -78,6 +77,7 @@ def ExaError( FIXME: Due to legacy reasons this function currently still may raise an `ValueError` (Refactoring Required). Potential error scenarios which should taken into account are the following ones: + * E-ERP-1: Invalid error code provided params: * Original ErrorCode diff --git a/exasol/error/_parse.py b/exasol/error/_parse.py new file mode 100644 index 0000000..7d20086 --- /dev/null +++ b/exasol/error/_parse.py @@ -0,0 +1,235 @@ +import ast +import io +from contextlib import ExitStack +from dataclasses import dataclass +from pathlib import Path +from typing import Generator, Iterable, List, Optional, Tuple, Union + +from exasol.error._report import ErrorCodeDetails, Placeholder + + +class _ExaErrorNodeWalker: + @staticmethod + def _is_exa_error(node: ast.AST) -> bool: + if not isinstance(node, ast.Call): + return False + name = getattr(node.func, "id", "") + name = getattr(node.func, "attr", "") if name == "" else name + return name == "ExaError" + + def __init__(self, root_node: ast.AST): + self._root = root_node + + def __iter__(self) -> Generator[ast.Call, None, None]: + return ( + node + for node in ast.walk(self._root) + if _ExaErrorNodeWalker._is_exa_error(node) and isinstance(node, ast.Call) + ) + + +class Validator: + @dataclass(frozen=True) + class Error: + message: str + file: str + line_number: Optional[int] + + @dataclass(frozen=True) + class Warning: + message: str + file: str + line_number: Optional[int] + + def __init__(self): + self._warnings: List["Validator.Warning"] = list() + self._errors: List[Validator.Error] = list() + self._error_msg = "{type} only can contain constant values, details: {value}" + + @property + def errors(self) -> Iterable["Validator.Error"]: + return self._errors + + @property + def warnings(self) -> Iterable["Validator.Warning"]: + return self._warnings + + def validate( + self, node: ast.Call, file: str + ) -> Tuple[List["Validator.Error"], List["Validator.Warning"]]: + code: ast.Constant + message: ast.Constant + mitigations: Union[ast.Constant, ast.List] + parameters: ast.Dict + code, message, mitigations, parameters = node.args + + # TODO: Add/Collect additional warnings: + # * error message/mitigation defines a placeholder, but no parameter is provided + # * error defines a parameter, but it is never used + # TODO: Add/Collect additional errors: + # * check for invalid error code format + # + # See also [Github Issue #23](https://github.com/exasol/error-reporting-python/issues/23) + + # make sure all parameters have the valid type + self._validate_code(code, file) + self._validate_message(message, file) + self._validate_mitigations(mitigations, file) + self._validate_parameters(parameters, file) + + return self._errors, self._warnings + + def _validate_code(self, code: ast.Constant, file: str): + if not isinstance(code, ast.Constant): + self._errors.append( + self.Error( + message=self._error_msg.format( + type="error-codes", value=type(code) + ), + file=file, + line_number=code.lineno, + ) + ) + + def _validate_message(self, node: ast.Constant, file: str): + if not isinstance(node, ast.Constant): + self._errors.append( + self.Error( + message=self._error_msg.format(type="message", value=type(node)), + file=file, + line_number=node.lineno, + ) + ) + + def _validate_mitigations(self, node: Union[ast.Constant, ast.List], file: str): + if not isinstance(node, ast.List) and not isinstance(node, ast.Constant): + self._errors.append( + self.Error( + message=self._error_msg.format( + type="mitigations", value=type(node) + ), + file=file, + line_number=node.lineno, + ) + ) + + if isinstance(node, ast.List): + invalid = [e for e in node.elts if not isinstance(e, ast.Constant)] + self._errors.extend( + [ + self.Error( + message=self._error_msg.format( + type="mitigations", value=type(e) + ), + file=file, + line_number=e.lineno, + ) + for e in invalid + ] + ) + + def _validate_parameters(self, node: ast.Dict, file: str): + # Validate parameters + for key in node.keys: + if not isinstance(key, ast.Constant): + self._errors.append( + self.Error( + message=self._error_msg.format(type="key", value=type(key)), + file=file, + line_number=key.lineno, + ) + ) + for value in node.values: + if isinstance(value, ast.Call): + description = value.args[1] + if not isinstance(description, ast.Constant): + self._errors.append( + self.Error( + message=self._error_msg.format( + type="description", value=type(description) + ), + file=file, + line_number=value.lineno, + ) + ) + + +class ErrorCollector: + def __init__(self, root: ast.AST, filename: str = ""): + self._filename = filename + self._root = root + self._validator = Validator() + self._error_definitions: List[ErrorCodeDetails] = list() + + @property + def error_definitions(self) -> List[ErrorCodeDetails]: + return self._error_definitions + + @property + def errors(self) -> Iterable["Validator.Error"]: + return self._validator.errors + + @property + def warnings(self) -> Iterable["Validator.Warning"]: + return self._validator.warnings + + def _make_error(self, node: ast.Call) -> ErrorCodeDetails: + code: ast.Constant + message: ast.Constant + mitigations: Union[ast.List, ast.Constant] + parameters: ast.Dict + code, message, mitigations, parameters = node.args + + def normalize(params): + for k, v in zip(params.keys, params.keys): + if isinstance(v, ast.Call): + yield k.value, v[1] + else: + yield k.value, "" + + return ErrorCodeDetails( + identifier=code.value, + message=message.value, + messagePlaceholders=[ + Placeholder(name, description) + for name, description in normalize(parameters) + ], + description=None, + internalDescription=None, + potentialCauses=None, + mitigations=[m.value for m in mitigations.elts] + if not isinstance(mitigations, str) + else [mitigations], + sourceFile=self._filename, + sourceLine=node.lineno, + contextHash=None, + ) + + def collect(self) -> None: + for node in _ExaErrorNodeWalker(self._root): + errors, warnings = self._validator.validate(node, self._filename) + if errors: + # stop if we encountered any error + return + error_definition = self._make_error(node) + self._error_definitions.append(error_definition) + + +def parse_file( + file: Union[str, Path, io.FileIO] +) -> Tuple[ + Iterable[ErrorCodeDetails], + Iterable["Validator.Warning"], + Iterable["Validator.Error"], +]: + with ExitStack() as stack: + f = ( + file + if isinstance(file, io.TextIOBase) + else stack.enter_context(open(file, "r")) + ) + root_node = ast.parse(f.read()) + collector = ErrorCollector(root_node, f.name) + collector.collect() + + return collector.error_definitions, collector.warnings, collector.errors diff --git a/exasol/error/_report.py b/exasol/error/_report.py new file mode 100644 index 0000000..0132d2d --- /dev/null +++ b/exasol/error/_report.py @@ -0,0 +1,42 @@ +import json +from dataclasses import asdict, dataclass, is_dataclass +from typing import List, Optional + + +class JsonEncoder(json.JSONEncoder): + """Json encoder with dataclass support""" + + def default(self, obj): + if is_dataclass(obj): + return asdict(obj) + return super().default(obj) + + +@dataclass(frozen=True) +class Placeholder: + """ + Placeholder according to schema specification. + https://schemas.exasol.com/error_code_report-1.0.0.json + """ + + placeholder: str + description: Optional[str] + + +@dataclass +class ErrorCodeDetails: + """ + Error code details according to schema specification. + https://schemas.exasol.com/error_code_report-1.0.0.json + """ + + identifier: str + message: Optional[str] + messagePlaceholders: Optional[List[Placeholder]] + description: Optional[str] + internalDescription: Optional[str] + potentialCauses: Optional[List[str]] + mitigations: Optional[List[str]] + sourceFile: Optional[str] + sourceLine: Optional[int] + contextHash: Optional[str] diff --git a/pyproject.toml b/pyproject.toml index 05d8def..6eadf19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,8 @@ license = "MIT" authors = [ "Umit Buyuksahin ", - "Torsten Kilias " + "Torsten Kilias ", + "Nicola Coretti " ] @@ -25,9 +26,13 @@ keywords = ['exasol', 'python', 'error-reporting'] [tool.poetry.dependencies] python = "^3.8" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] pytest = "^7.1.2" +prysk = {extras = ["pytest-plugin"], version = "^0.15.1"} [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +ec = 'exasol.error._cli:main' \ No newline at end of file diff --git a/test/integration/generate-subcommand.t b/test/integration/generate-subcommand.t new file mode 100644 index 0000000..5bbb080 --- /dev/null +++ b/test/integration/generate-subcommand.t @@ -0,0 +1,115 @@ +Prepare Test + + $ export ROOT=$TESTDIR/../../ + + $ cat > pymodule.py << EOF + > from exasol import error + > from exasol.error import Parameter + > error1 = error.ExaError( + > "E-TEST-1", + > "this is an error", + > ["no mitigation available"], + > {"param": Parameter("value", "some description")}, + > ) + > error2 = error.ExaError( + > "E-TEST-2", "this is an error", ["no mitigation available"], {"param": "value"} + > ) + > EOF + +Test module entry point + + $ python -m exasol.error --debug generate modulename 1.2.0 . | python -m json.tool --json-lines + { + "$schema": "https://schemas.exasol.com/error_code_report-1.0.0.json", + "projectName": "modulename", + "projectVersion": "1.2.0", + "errorCodes": [ + { + "identifier": "E-TEST-1", + "message": "this is an error", + "messagePlaceholders": [ + { + "placeholder": "param", + "description": "" + } + ], + "description": null, + "internalDescription": null, + "potentialCauses": null, + "mitigations": [ + "no mitigation available" + ], + "sourceFile": "pymodule.py", + "sourceLine": 3, + "contextHash": null + }, + { + "identifier": "E-TEST-2", + "message": "this is an error", + "messagePlaceholders": [ + { + "placeholder": "param", + "description": "" + } + ], + "description": null, + "internalDescription": null, + "potentialCauses": null, + "mitigations": [ + "no mitigation available" + ], + "sourceFile": "pymodule.py", + "sourceLine": 9, + "contextHash": null + } + ] + } + +Test cli command + + $ ec --debug generate modulename 1.2.0 . | python -m json.tool --json-lines + { + "$schema": "https://schemas.exasol.com/error_code_report-1.0.0.json", + "projectName": "modulename", + "projectVersion": "1.2.0", + "errorCodes": [ + { + "identifier": "E-TEST-1", + "message": "this is an error", + "messagePlaceholders": [ + { + "placeholder": "param", + "description": "" + } + ], + "description": null, + "internalDescription": null, + "potentialCauses": null, + "mitigations": [ + "no mitigation available" + ], + "sourceFile": "pymodule.py", + "sourceLine": 3, + "contextHash": null + }, + { + "identifier": "E-TEST-2", + "message": "this is an error", + "messagePlaceholders": [ + { + "placeholder": "param", + "description": "" + } + ], + "description": null, + "internalDescription": null, + "potentialCauses": null, + "mitigations": [ + "no mitigation available" + ], + "sourceFile": "pymodule.py", + "sourceLine": 9, + "contextHash": null + } + ] + } diff --git a/test/integration/parse-subcommand.t b/test/integration/parse-subcommand.t new file mode 100644 index 0000000..9bc4ddd --- /dev/null +++ b/test/integration/parse-subcommand.t @@ -0,0 +1,101 @@ +Prepare Test + + $ export ROOT=$TESTDIR/../../ + + $ cat > pymodule.py << EOF + > from exasol import error + > from exasol.error import Parameter + > error1 = error.ExaError( + > "E-TEST-1", + > "this is an error", + > ["no mitigation available"], + > {"param": Parameter("value", "some description")}, + > ) + > error2 = error.ExaError( + > "E-TEST-2", "this is an error", ["no mitigation available"], {"param": "value"} + > ) + > EOF + +Test module entry point + + $ python -m exasol.error --debug parse pymodule.py | python -m json.tool --json-lines + { + "identifier": "E-TEST-1", + "message": "this is an error", + "messagePlaceholders": [ + { + "placeholder": "param", + "description": "" + } + ], + "description": null, + "internalDescription": null, + "potentialCauses": null, + "mitigations": [ + "no mitigation available" + ], + "sourceFile": "pymodule.py", + "sourceLine": 3, + "contextHash": null + } + { + "identifier": "E-TEST-2", + "message": "this is an error", + "messagePlaceholders": [ + { + "placeholder": "param", + "description": "" + } + ], + "description": null, + "internalDescription": null, + "potentialCauses": null, + "mitigations": [ + "no mitigation available" + ], + "sourceFile": "pymodule.py", + "sourceLine": 9, + "contextHash": null + } + +Test cli command + + $ ec --debug parse pymodule.py | python -m json.tool --json-lines + { + "identifier": "E-TEST-1", + "message": "this is an error", + "messagePlaceholders": [ + { + "placeholder": "param", + "description": "" + } + ], + "description": null, + "internalDescription": null, + "potentialCauses": null, + "mitigations": [ + "no mitigation available" + ], + "sourceFile": "pymodule.py", + "sourceLine": 3, + "contextHash": null + } + { + "identifier": "E-TEST-2", + "message": "this is an error", + "messagePlaceholders": [ + { + "placeholder": "param", + "description": "" + } + ], + "description": null, + "internalDescription": null, + "potentialCauses": null, + "mitigations": [ + "no mitigation available" + ], + "sourceFile": "pymodule.py", + "sourceLine": 9, + "contextHash": null + } diff --git a/test/unit/cli_test.py b/test/unit/cli_test.py new file mode 100644 index 0000000..61e8307 --- /dev/null +++ b/test/unit/cli_test.py @@ -0,0 +1,189 @@ +import ast +import sys +from inspect import cleandoc + +import pytest + +from exasol.error._parse import ErrorCodeDetails, ErrorCollector, Placeholder, Validator + +AST_NAME_CLASS = "ast.Name" if sys.version_info.minor > 8 else "_ast.Name" + + +@pytest.mark.parametrize( + ["src", "expected"], + [ + ( + cleandoc( + """ + from exasol import error + + error1 = error.ExaError( + "E-TEST-1", + "this is an error", + ["no mitigation available"], + {"param": error.Parameter("value", "description")}, + ) + """ + ), + [ + ErrorCodeDetails( + identifier="E-TEST-1", + message="this is an error", + messagePlaceholders=[ + Placeholder(placeholder="param", description="") + ], + description=None, + internalDescription=None, + potentialCauses=None, + mitigations=["no mitigation available"], + sourceFile="", + sourceLine=3, + contextHash=None, + ) + ], + ), + ], +) +def test_ErrorCollector_error_definitions(src, expected): + root_node = ast.parse(src) + collector = ErrorCollector(root_node) + collector.collect() + assert expected == collector.error_definitions + + +@pytest.mark.parametrize( + ["src", "expected"], + [ + ( + cleandoc( + """ + from exasol import error + from exasol.error import Parameter + + var = input("description: ") + + error1 = error.ExaError( + "E-TEST-1", + "this is an error", + ["no mitigation available"], + {"param": Parameter("value", var)}, + ) + """ + ), + [ + Validator.Error( + message=f"description only can contain constant values, details: ", + file="", + line_number=10, + ) + ], + ), + ( + cleandoc( + """ + from exasol import error + from exasol.error import Parameter + + var = input("description: ") + + error1 = error.ExaError( + var, + "this is an error", + ["no mitigation available"], + {"param": Parameter("value", "description")}, + ) + """ + ), + [ + Validator.Error( + message=f"error-codes only can contain constant values, details: ", + file="", + line_number=7, + ) + ], + ), + ( + cleandoc( + """ + from exasol import error + from exasol.error import Parameter + + var = input("description: ") + + error1 = error.ExaError( + "E-TEST-1", + "this is an error", + [var], + {"param": Parameter("value", "description")}, + ) + """ + ), + [ + Validator.Error( + message=f"mitigations only can contain constant values, details: ", + file="", + line_number=9, + ) + ], + ), + ( + cleandoc( + """ + from exasol import error + from exasol.error import Parameter + + var = input("description: ") + + error1 = error.ExaError( + "E-TEST-1", + "this is an error", + var, + {"param": Parameter("value", "description")}, + ) + """ + ), + [ + Validator.Error( + message=f"mitigations only can contain constant values, details: ", + file="", + line_number=9, + ) + ], + ), + ], +) +def test_ErrorCollector_errors(src, expected): + root_node = ast.parse(src) + collector = ErrorCollector(root_node) + collector.collect() + assert expected == collector.errors + + +@pytest.mark.parametrize( + ["src", "expected"], + [ + ( + cleandoc( + """ + from exasol import error + from exasol.error import Parameter + + var = input("description: ") + + error1 = error.ExaError( + "E-TEST-1", + "this is an error", + ["no mitigation available"], + {"param": Parameter("value", var)}, + ) + """ + ), + [], + ), + ], +) +def test_ErrorCollector_warnings(src, expected): + root_node = ast.parse(src) + collector = ErrorCollector(root_node) + collector.collect() + assert expected == collector.warnings