client/error.py (387 lines of code) (raw):
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import dataclasses
import json
import logging
import os
import sys
from pathlib import Path
from typing import Any, Dict, Sequence, Union, Optional, List
import click
from . import command_arguments, log, terminal
from .log import Color, Format
LOG: logging.Logger = logging.getLogger(__name__)
class ErrorParsingFailure(Exception):
pass
@dataclasses.dataclass(frozen=True)
class Error:
line: int
column: int
stop_line: int
stop_column: int
path: Path
code: int
name: str
description: str
long_description: str = ""
concise_description: str = ""
@staticmethod
def from_json(error_json: Dict[str, Any]) -> "Error":
try:
return Error(
line=error_json["line"],
column=error_json["column"],
stop_line=error_json["stop_line"],
stop_column=error_json["stop_column"],
path=Path(error_json["path"]),
code=error_json["code"],
name=error_json["name"],
description=error_json["description"],
long_description=error_json.get("long_description", ""),
concise_description=error_json.get("concise_description", ""),
)
except KeyError as key_error:
message = f"Missing field from error json: {key_error}"
raise ErrorParsingFailure(message) from key_error
except TypeError as type_error:
message = f"Field type mismatch: {type_error}"
raise ErrorParsingFailure(message) from type_error
@staticmethod
def from_string(error_string: str) -> "Error":
try:
return Error.from_json(json.loads(error_string))
except json.JSONDecodeError as decode_error:
message = f"Cannot parse JSON: {decode_error}"
raise ErrorParsingFailure(message) from decode_error
def relativize_path(self, against: Path) -> "Error":
relativized_path = Path(os.path.relpath(str(self.path), str(against)))
return Error(
line=self.line,
column=self.column,
stop_line=self.stop_line,
stop_column=self.stop_column,
path=relativized_path,
code=self.code,
name=self.name,
description=self.description,
long_description=self.long_description,
concise_description=self.concise_description,
)
def to_json(self) -> Dict[str, Any]:
return {
"line": self.line,
"column": self.column,
"stop_line": self.stop_line,
"stop_column": self.stop_column,
"path": str(self.path),
"code": self.code,
"name": self.name,
"description": self.description,
"long_description": self.long_description,
"concise_description": self.concise_description,
}
def to_text(self) -> str:
path = click.style(str(self.path), fg="red")
line = click.style(str(self.line), fg="yellow")
column = click.style(str(self.column), fg="yellow")
return f"{path}:{line}:{column} {self.description}"
def to_sarif(self) -> Dict[str, Any]:
return {
"ruleId": "PYRE-ERROR-" + str(self.code),
"level": "error",
"message": {"text": self.description},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": str(self.path),
},
"region": {
"startLine": self.line,
"startColumn": self.column + 1,
"endLine": self.stop_line,
"endColumn": self.stop_column,
},
},
},
],
}
def get_sarif_rule(self) -> Dict[str, Any]:
return {
"id": "PYRE-ERROR-" + str(self.code),
"name": self.name.title().replace(" ", ""),
"shortDescription": {"text": self.name},
"helpUri": "https://www.pyre-check.org",
"help": {"text": self.name},
}
class LegacyError:
error: Error
ignore_error: bool = False
def __init__(
self,
error: Error,
ignore_error: bool,
) -> None:
self.error = error
self.ignore_error = ignore_error
@staticmethod
def create(
error: Dict[str, Any],
ignore_error: bool = False,
) -> "LegacyError":
return LegacyError(
error=Error.from_json(error),
ignore_error=ignore_error or error.get("ignore_error", False),
)
def with_path(self, path: str) -> "LegacyError":
return LegacyError(
error=dataclasses.replace(self.error, path=path),
ignore_error=self.ignore_error,
)
def __repr__(self) -> str:
if terminal.is_capable(file=sys.stdout):
key = self._key_with_color()
else:
key = self.__key()
return key + " " + self.error.description
def __key(self) -> str:
return f"{self.error.path}:{self.error.line}:{self.error.column}"
def _key_with_color(self) -> str:
return (
Color.RED
+ str(self.error.path)
+ Format.CLEAR
+ ":"
+ Color.YELLOW
+ str(self.error.line)
+ Format.CLEAR
+ ":"
+ Color.YELLOW
+ str(self.error.column)
+ Format.CLEAR
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, LegacyError):
return False
return self.__key() == other.__key()
def __lt__(self, other: object) -> bool:
if not isinstance(other, LegacyError):
return False
return self.__key() < other.__key()
def __hash__(self) -> int:
return hash(self.__key())
def is_ignored(self) -> bool:
return self.ignore_error
def to_json(self) -> Dict[str, Any]:
error_mapping = self.error.to_json()
error_mapping["ignore_error"] = self.ignore_error
return error_mapping
def to_text(self) -> str:
path = click.style(str(self.error.path), fg="red")
line = click.style(str(self.error.line), fg="yellow")
column = click.style(str(self.error.column), fg="yellow")
return f"{path}:{line}:{column} {self.error.description}"
def to_sarif(self) -> Dict[str, Any]:
return self.error.to_sarif()
def get_sarif_rule(self) -> Dict[str, Any]:
return self.error.get_sarif_rule()
@dataclasses.dataclass(frozen=True)
class TaintConfigurationError:
path: Optional[Path]
description: str
code: int
@staticmethod
def from_json(error_json: Dict[str, Any]) -> "TaintConfigurationError":
try:
return TaintConfigurationError(
path=Path(error_json["path"])
if error_json["path"] is not None
else None,
description=error_json["description"],
code=error_json["code"],
)
except KeyError as key_error:
message = f"Missing field from error json: {key_error}"
raise ErrorParsingFailure(message) from key_error
except TypeError as type_error:
message = f"Field type mismatch: {type_error}"
raise ErrorParsingFailure(message) from type_error
@staticmethod
def from_string(error_string: str) -> "TaintConfigurationError":
try:
return TaintConfigurationError.from_json(json.loads(error_string))
except json.JSONDecodeError as decode_error:
message = f"Cannot parse JSON: {decode_error}"
raise ErrorParsingFailure(message) from decode_error
def to_json(self) -> Dict[str, Any]:
return {
"path": str(self.path) if self.path is not None else None,
"description": self.description,
"code": self.code,
}
def to_text(self) -> str:
path = click.style(str(self.path or "?"), fg="red")
return f"{path} {self.description}"
def to_sarif(self) -> Dict[str, Any]:
return {
"ruleId": "PYRE-TAINT-CONFIGURATION-ERROR-" + str(self.code)
if self.code is not None
else "PYRE-TAINT-CONFIGURATION-ERROR-MDL",
"level": "error",
"message": {"text": self.description},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": str(self.path) if self.path is not None else None,
},
"region": {
"startLine": 0,
"startColumn": 0,
"endLine": 0,
"endColumn": 1,
},
},
},
],
}
def get_sarif_rule(self) -> Dict[str, Any]:
return {
"id": "PYRE-TAINT-CONFIGURATION-ERROR-" + str(self.code)
if self.code is not None
else "PYRE-TAINT-CONFIGURATION-ERROR-MDL",
"name": "TaintConfigurationError",
"shortDescription": {"text": "Taint configuration error"},
"helpUri": "https://www.pyre-check.org",
"help": {"text": "Taint Configuration error"},
}
@dataclasses.dataclass(frozen=True)
class ModelVerificationError:
line: int
column: int
stop_line: int
stop_column: int
path: Optional[Path]
description: str
code: Optional[int]
@staticmethod
def from_json(error_json: Dict[str, Any]) -> "ModelVerificationError":
try:
return ModelVerificationError(
line=error_json["line"],
column=error_json["column"],
stop_line=error_json["stop_line"],
stop_column=error_json["stop_column"],
path=Path(error_json["path"])
if error_json["path"] is not None
else None,
description=error_json["description"],
code=error_json.get("code"),
)
except KeyError as key_error:
message = f"Missing field from error json: {key_error}"
raise ErrorParsingFailure(message) from key_error
except TypeError as type_error:
message = f"Field type mismatch: {type_error}"
raise ErrorParsingFailure(message) from type_error
@staticmethod
def from_string(error_string: str) -> "ModelVerificationError":
try:
return ModelVerificationError.from_json(json.loads(error_string))
except json.JSONDecodeError as decode_error:
message = f"Cannot parse JSON: {decode_error}"
raise ErrorParsingFailure(message) from decode_error
def to_json(self) -> Dict[str, Any]:
return {
"line": self.line,
"column": self.column,
"stop_line": self.stop_line,
"stop_column": self.stop_column,
"path": str(self.path) if self.path is not None else None,
"description": self.description,
"code": self.code,
}
def to_text(self) -> str:
path = click.style(str(self.path or "?"), fg="red")
line = click.style(str(self.line), fg="yellow")
column = click.style(str(self.column), fg="yellow")
return f"{path}:{line}:{column} {self.description}"
def to_sarif(self) -> Dict[str, Any]:
return {
"ruleId": "PYRE-MODEL-VERIFICATION-ERROR-" + str(self.code)
if self.code is not None
else "PYRE-MODEL-VERIFICATION-ERROR-MDL",
"level": "error",
"message": {"text": self.description},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": str(self.path) if self.path is not None else None,
},
"region": {
"startLine": self.line,
"startColumn": self.column,
"endLine": self.stop_line,
"endColumn": self.stop_column + 1,
},
},
},
],
}
def get_sarif_rule(self) -> Dict[str, Any]:
return {
"id": "PYRE-MODEL-VERIFICATION-ERROR-" + str(self.code)
if self.code is not None
else "PYRE-MODEL-VERIFICATION-ERROR-MDL",
"name": "ModelVerificationError",
"shortDescription": {"text": "Model verification error"},
"helpUri": "https://www.pyre-check.org",
"help": {"text": "Model Verification error"},
}
def errors_to_sarif(
errors: Union[
Sequence[Error],
Sequence[LegacyError],
Sequence[TaintConfigurationError],
Sequence[ModelVerificationError],
]
) -> Dict[str, Any]:
results: List[Dict[str, Any]] = [error.to_sarif() for error in errors]
rules: List[Dict[str, Any]] = [error.get_sarif_rule() for error in errors]
return {
"version": "2.1.0",
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"runs": [
{
"tool": {
"driver": {
"name": "Pyre",
"informationUri": "https://www.pyre-check.org",
# Remove duplicate rules
"rules": list({rule["id"]: rule for rule in rules}.values()),
}
},
"results": results,
}
],
}
def print_errors(
errors: Union[
Sequence[Error],
Sequence[LegacyError],
Sequence[TaintConfigurationError],
Sequence[ModelVerificationError],
],
output: str,
error_kind: str = "type",
) -> None:
length = len(errors)
if length != 0:
suffix = "s" if length > 1 else ""
LOG.error(f"Found {length} {error_kind} error{suffix}!")
else:
LOG.log(log.SUCCESS, f"No {error_kind} errors found")
if output == command_arguments.TEXT:
log.stdout.write("\n".join([error.to_text() for error in errors]))
elif output == command_arguments.SARIF:
log.stdout.write(json.dumps(errors_to_sarif(errors)))
else:
log.stdout.write(json.dumps([error.to_json() for error in errors]))