Skip to content

Commit

Permalink
Merge 3711fbe into 2b21032
Browse files Browse the repository at this point in the history
  • Loading branch information
mristin committed Aug 25, 2020
2 parents 2b21032 + 3711fbe commit 89b7cd8
Show file tree
Hide file tree
Showing 3 changed files with 98 additions and 10 deletions.
2 changes: 2 additions & 0 deletions README.rst
Expand Up @@ -27,6 +27,8 @@ The following checks are performed:
+---------------------------------------------------------------------------------------+----------------------+
| Description | Identifier |
+=======================================================================================+======================+
| File should be read and decoded correctly. | unreadable |
+---------------------------------------------------------------------------------------+----------------------+
| A preconditions expects a subset of function's arguments. | pre-invalid-arg |
+---------------------------------------------------------------------------------------+----------------------+
| A snapshot expects at most an argument element of the function's arguments. | snapshot-invalid-arg |
Expand Down
53 changes: 43 additions & 10 deletions icontract_lint/__init__.py
Expand Up @@ -4,6 +4,8 @@
import json
import pathlib
import re
import sys
import traceback
from typing import Set, List, Mapping, Optional, TextIO, Any

import astroid
Expand All @@ -27,6 +29,7 @@
class ErrorID(enum.Enum):
"""Enumerate error identifiers."""

UNREADABLE = "unreadable"
PRE_INVALID_ARG = "pre-invalid-arg"
SNAPSHOT_INVALID_ARG = "snapshot-invalid-arg"
SNAPSHOT_WO_CAPTURE = "snapshot-wo-capture"
Expand All @@ -42,7 +45,7 @@ class ErrorID(enum.Enum):

@icontract.invariant(lambda self: len(self.description) > 0)
@icontract.invariant(lambda self: len(self.filename) > 0)
@icontract.invariant(lambda self: self.lineno >= 1)
@icontract.invariant(lambda self: self.lineno is None or self.lineno >= 1)
class Error:
"""
Represent a linter error.
Expand All @@ -64,8 +67,8 @@ class Error:

@icontract.require(lambda description: len(description) > 0)
@icontract.require(lambda filename: len(filename) > 0)
@icontract.require(lambda lineno: lineno >= 1)
def __init__(self, identifier: ErrorID, description: str, filename: str, lineno: int) -> None:
@icontract.require(lambda lineno: lineno is None or lineno >= 1)
def __init__(self, identifier: ErrorID, description: str, filename: str, lineno: Optional[int]) -> None:
"""Initialize with the given properties."""
self.identifier = identifier
self.description = description
Expand All @@ -74,8 +77,19 @@ def __init__(self, identifier: ErrorID, description: str, filename: str, lineno:

def as_mapping(self) -> Mapping[str, Any]:
"""Transform the error to a mapping that can be converted to JSON and similar formats."""
return collections.OrderedDict([("identifier", self.identifier.value), ("description", str(self.description)),
("filename", self.filename), ("lineno", self.lineno)])
# yapf: disable
result = collections.OrderedDict(
[
("identifier", self.identifier.value),
("description", str(self.description)),
("filename", self.filename)
])
# yapf: enable

if self.lineno is not None:
result['lineno'] = self.lineno

return result


class _AstroidVisitor:
Expand Down Expand Up @@ -479,7 +493,10 @@ def check_file(path: pathlib.Path) -> List[Error]:
:param path: path to the file
:return: list of lint errors
"""
text = path.read_text()
try:
text = path.read_text()
except Exception as err: # pylint: disable=broad-except
return [Error(identifier=ErrorID.UNREADABLE, description=str(err), filename=str(path), lineno=None)]

for line in text.splitlines():
if _DISABLED_DIRECTIVE_RE.match(line):
Expand All @@ -496,14 +513,27 @@ def check_file(path: pathlib.Path) -> List[Error]:
cause = err.__cause__
assert isinstance(cause, SyntaxError)

lineno = -1 if cause.lineno is None else cause.lineno # pylint: disable=no-member

return [
Error(
identifier=ErrorID.INVALID_SYNTAX,
description=cause.msg, # pylint: disable=no-member
filename=str(path),
lineno=lineno) # pylint: disable=no-member
lineno=cause.lineno) # pylint: disable=no-member
]
except Exception as err: # pylint: disable=broad-except
stack_summary = traceback.extract_tb(sys.exc_info()[2])
if len(stack_summary) == 0:
raise AssertionError("Expected at least one element in the traceback") from err

last_frame = stack_summary[-1] # type: traceback.FrameSummary

return [
Error(
identifier=ErrorID.UNREADABLE,
description="Astroid failed to parse the file: {} ({} at line {} in {})".format(
err, last_frame.filename, last_frame.lineno, last_frame.name),
filename=str(path),
lineno=None)
]

lint_visitor = _LintVisitor(filename=str(path))
Expand Down Expand Up @@ -558,7 +588,10 @@ def output_verbose(errors: List[Error], stream: TextIO) -> None:
:return:
"""
for err in errors:
stream.write("{}:{}: {} ({})\n".format(err.filename, err.lineno, err.description, err.identifier.value))
if err.lineno is not None:
stream.write("{}:{}: {} ({})\n".format(err.filename, err.lineno, err.description, err.identifier.value))
else:
stream.write("{}: {} ({})\n".format(err.filename, err.description, err.identifier.value))


def output_json(errors: List[Error], stream: TextIO) -> None:
Expand Down
53 changes: 53 additions & 0 deletions tests/test_icontract_lint.py
Expand Up @@ -7,6 +7,7 @@
import tempfile
import textwrap
import unittest
import unittest.mock
from typing import List, cast, TextIO

import icontract_lint
Expand All @@ -28,6 +29,58 @@ def __exit__(self, exc_type, exc_value, traceback):
sys.path.remove(str(self.path))


class TestCheckUnreadableFile(unittest.TestCase):
def test_read_failure(self) -> None:
# pylint: disable=no-self-use
class MockPath:
def read_text(self) -> str:
raise Exception("dummy exception")

def is_file(self) -> bool:
return True

def __str__(self) -> str:
return "some-path"

pth = cast(pathlib.Path, MockPath())
errors = icontract_lint.check_file(path=pth)

self.assertEqual(1, len(errors))
self.assertDictEqual({
'identifier': 'unreadable',
'description': 'dummy exception',
'filename': str(pth),
}, errors[0].as_mapping()) # type: ignore

def test_parse_failure(self) -> None:
# pylint: disable=no-self-use
class MockPath:
def read_text(self) -> str:
return "dummy content"

def is_file(self) -> bool:
return True

def __str__(self) -> str:
return "some-path"

pth = cast(pathlib.Path, MockPath())

with unittest.mock.patch('astroid.parse') as astroid_parse:

def blow_up(*args, **kwargs) -> None:
raise Exception("dummy exception")

astroid_parse.side_effect = blow_up

errors = icontract_lint.check_file(path=pth)

self.assertEqual(1, len(errors))
self.assertEqual('unreadable', errors[0].identifier.value)
self.assertTrue(errors[0].description.startswith("Astroid failed to parse the file: dummy exception ("))
self.assertEqual(str(pth), errors[0].filename)


class TestCheckFile(unittest.TestCase):
def test_wo_contracts(self):
text = textwrap.dedent("""\
Expand Down

0 comments on commit 89b7cd8

Please sign in to comment.