Source code for rattle.ftypes

# 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 platform
import re
import traceback
from collections.abc import Callable, Collection, Container, Iterable, Mapping, Sequence
from contextlib import AbstractContextManager
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, TypedDict, TypeVar

from libcst import CSTNode, CSTNodeT, FlattenSentinel, RemovalSentinel
from libcst._add_slots import add_slots
from libcst.metadata import CodePosition, CodeRange
from packaging.version import Version

__all__ = ["CodePosition", "CodeRange", "Version"]

T = TypeVar("T")

STDIN = Path("-")

FileContent = bytes
RuleOptionScalar = str | int | float | bool
RuleOptionValue = RuleOptionScalar | list[Any] | dict[str, Any]
RuleOptionTypes = (str, int, float, bool)
RuleOptions = dict[str, RuleOptionValue]
RuleOptionsTable = dict[str, RuleOptions]

NodeReplacement = CSTNodeT | FlattenSentinel[CSTNodeT] | RemovalSentinel

Metrics = dict[str, Any]
MetricsHook = Callable[[Metrics], None]

VisitorMethod = Callable[[CSTNode], None]
VisitHook = Callable[[str], AbstractContextManager[None]]


class OutputFormat(str, Enum):
    custom = "custom"
    rattle = "rattle"
    # json = "json"  # TODO
    vscode = "vscode"


[docs] @dataclass(frozen=True) class Invalid: code: str range: CodeRange | None = None expected_message: str | None = None expected_replacement: str | None = None options: RuleOptions | None = None
[docs] @dataclass(frozen=True) class Valid: code: str options: RuleOptions | None = None
LintIgnoreRegex = re.compile( r""" \#\s* # leading hash and whitespace rattle:\s*ignore # directive (?: \s*\[ (?P<rattle_names> [a-z][a-z0-9]*(?:-[a-z0-9]+)* # first rule name (?:,\s*[a-z][a-z0-9]*(?:-[a-z0-9]+)*)* # subsequent rule names ) \] | (?!\w)(?!\s+\w)(?!\s*\[) # do not accept bare names or invalid brackets ) """, re.VERBOSE, ) @dataclass(frozen=True) class LintIgnoreDirective: names: str | None def parse_lint_ignore_comment(comment: str) -> LintIgnoreDirective | None: match = LintIgnoreRegex.search(comment) if match is None: return None return LintIgnoreDirective(names=match.group("rattle_names")) QualifiedRuleRegex = re.compile( r""" ^ (?P<module> (?P<local>\.)? [a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)* ) (?::(?P<name>[a-z][a-z0-9]*(?:-[a-z0-9]+)*))? $ """, re.VERBOSE, ) RuleNameSelectorRegex = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$") class QualifiedRuleRegexResult(TypedDict): module: str name: str | None local: str | None def is_sequence(value: object) -> bool: return isinstance(value, Sequence) and not isinstance(value, (str, bytes)) def is_rule_option_value(value: object) -> bool: if isinstance(value, RuleOptionTypes): return True if is_sequence(value): assert isinstance(value, Sequence) return all(is_rule_option_value(item) for item in value) if isinstance(value, Mapping): return all( isinstance(key, str) and is_rule_option_value(item) for key, item in value.items() ) return False def is_collection(value: object) -> bool: return isinstance(value, Iterable) and not isinstance(value, (str, bytes))
[docs] @dataclass(frozen=True) class QualifiedRule: module: str name: str | None = None local: str | None = None root: Path | None = field(default=None, hash=False, compare=False) def __str__(self) -> str: return self.module + (f":{self.name}" if self.name else "") def __lt__(self, other: object) -> bool: if isinstance(other, QualifiedRule): return str(self) < str(other) return NotImplemented
[docs] @dataclass(frozen=True) class RuleNameSelector: value: str def __str__(self) -> str: return self.value def __lt__(self, other: object) -> bool: if isinstance(other, RuleNameSelector): return self.value < other.value return NotImplemented
RuleSelector = QualifiedRule | RuleNameSelector
[docs] @dataclass(frozen=True) class Tags(Container[str]): include: tuple[str, ...] = () exclude: tuple[str, ...] = ()
[docs] @staticmethod def parse(value: str | None) -> "Tags": if not value: return Tags() include = set() exclude = set() tokens = {token.strip() for token in value.lower().split(",") if token.strip()} for token in tokens: if token[0] in "!^-": exclude.add(token[1:]) else: include.add(token) return Tags( include=tuple(sorted(include)), exclude=tuple(sorted(exclude)), )
def __bool__(self) -> bool: return bool(self.include) or bool(self.exclude) def __contains__(self, value: object) -> bool: tags: Collection[str] if isinstance(value, str): tags = (value,) elif isinstance(value, Collection): tags = value else: return False if any(tag in self.exclude for tag in tags): return False return bool(not self.include or any(tag in self.include for tag in tags))
[docs] @dataclass class Options: """Command-line options to affect runtime behavior.""" debug: bool | None = None config_file: Path | None = None exclude: Sequence[str] = () extend_exclude: Sequence[str] = () jobs: int | None = None tags: Tags | None = None rules: Sequence[RuleSelector] = () output_format: OutputFormat | None = None output_template: str | None = None print_metrics: bool = False no_format: bool = False
@dataclass class LSPOptions: """Command-line options to affect LSP runtime behavior.""" tcp: int | None ws: int | None stdio: bool = True debounce_interval: float = 0.5
[docs] @dataclass class Config: """Materialized configuration valid for processing a single file.""" path: Path = field(default_factory=Path) root: Path = field(default_factory=Path.cwd) excluded: bool = False # feature flags enable_root_import: bool | Path = False # rule selection enable: list[RuleSelector] = field(default_factory=list) disable: list[RuleSelector] = field(default_factory=list) rule_imports: list[RuleSelector] = field(default_factory=list) options: RuleOptionsTable = field(default_factory=dict) # filtering criteria python_version: Version | None = field( default_factory=lambda: Version(platform.python_version()) ) tags: Tags = field(default_factory=Tags) # post-run processing formatter: str | None = "auto" # output formatting options output_format: OutputFormat = OutputFormat.rattle output_template: str = "" def __post_init__(self) -> None: self.path = self.path.resolve() self.root = self.root.resolve()
@dataclass class RawConfig: path: Path data: dict[str, Any] def __post_init__(self) -> None: self.path = self.path.resolve()
[docs] @add_slots @dataclass(frozen=True) class LintViolation: """An individual lint error, with an optional replacement and expected diff.""" rule_name: str range: CodeRange | None message: str node: CSTNode replacement: NodeReplacement[CSTNode] | None diff: str = "" position_node: CSTNode | None = None @property def autofixable(self) -> bool: """Whether the violation includes a suggested replacement.""" return bool(self.replacement)
[docs] @dataclass class Result: """A single lint result for a given file and lint rule.""" path: Path violation: LintViolation | None error: tuple[Exception, str] | None = None source: FileContent | None = None config: Config | None = None
[docs] @classmethod def from_exception( cls, path: Path, error: Exception, *, source: FileContent | None = None, config: Config | None = None, ) -> "Result": return cls( path, violation=None, error=(error, traceback.format_exc()), source=source, config=config, )