add tclint
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
import argparse
|
||||
import pathlib
|
||||
from typing import Union, List
|
||||
from typing import Optional as OptionalType
|
||||
import dataclasses
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else:
|
||||
import tomli as tomllib
|
||||
|
||||
from voluptuous import Schema, Optional, And, Coerce, Invalid, Range
|
||||
|
||||
from src.tools.violations import Rule
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Config:
|
||||
"""This dataclass defines the supported Config fields and their default
|
||||
values. It provides an external interface for accessing config values.
|
||||
|
||||
The type annotations defined here are fairly loose - more specific type
|
||||
validation (and normalization) is defined by `validators` below.
|
||||
"""
|
||||
|
||||
exclude: List[str] = dataclasses.field(default_factory=list)
|
||||
ignore: List[Rule] = dataclasses.field(default_factory=list)
|
||||
commands: OptionalType[pathlib.Path] = dataclasses.field(default=None)
|
||||
extensions: List[str] = dataclasses.field(
|
||||
default_factory=lambda: ["tcl", "sdc", "xdc", "upf"]
|
||||
)
|
||||
style_indent: OptionalType[Union[str, int]] = dataclasses.field(default=None)
|
||||
style_line_length: int = dataclasses.field(default=100)
|
||||
style_max_blank_lines: int = dataclasses.field(default=2)
|
||||
style_indent_namespace_eval: bool = dataclasses.field(default=True)
|
||||
style_spaces_in_braces: bool = dataclasses.field(default=False)
|
||||
|
||||
def apply_cli_args(self, args):
|
||||
args_dict = vars(args)
|
||||
for field in dataclasses.fields(self):
|
||||
if field.name in args_dict and args_dict[field.name] is not None:
|
||||
setattr(self, field.name, args_dict[field.name])
|
||||
|
||||
# Special arguments that aren't handled automatically
|
||||
if "extend_exclude" in args_dict and args_dict["extend_exclude"] is not None:
|
||||
self.exclude.extend(args_dict["extend_exclude"])
|
||||
|
||||
if "extend_ignore" in args_dict and args_dict["extend_ignore"] is not None:
|
||||
self.ignore.extend(args_dict["extend_ignore"])
|
||||
|
||||
def get_indent(self) -> str:
|
||||
"""Get indent setting as string.
|
||||
|
||||
This helper does two things. One, it's a helpful utility to factor out the logic
|
||||
required for calculating the indent. Two, it lets us ergonomically store if the
|
||||
indentation is not set in style_indent, which the LSP relies on.
|
||||
"""
|
||||
if self.style_indent is None:
|
||||
# Default indent
|
||||
return " " * 4
|
||||
elif self.style_indent == "tab":
|
||||
return "\t"
|
||||
elif isinstance(self.style_indent, int):
|
||||
return " " * self.style_indent
|
||||
|
||||
# Should be unreachable, validated on ingestion of config
|
||||
raise ValueError(
|
||||
f"unexpected value for config.style_indent: {self.style_indent}"
|
||||
)
|
||||
|
||||
|
||||
# Validators using `voluptuous` library that check and normalize config inputs.
|
||||
# Used for checking both config files as well as config-related CLI args.
|
||||
|
||||
# Using these for CLI args adds a constraint that all non-boolean validators
|
||||
# need to be able to normalize a value from a string. This means one could put
|
||||
# e.g. a string representation of a list into a .toml config file, but we shouldn't
|
||||
# document this, since it won't be considered stable behavior.
|
||||
|
||||
|
||||
def _str2list(s):
|
||||
"""Handles string-to-list normalization."""
|
||||
if isinstance(s, str):
|
||||
if s == "":
|
||||
return []
|
||||
return [v.strip() for v in s.split(",")]
|
||||
return s
|
||||
|
||||
|
||||
_VALIDATORS = {
|
||||
# note: it's ok if paths don't exist - allows for generic
|
||||
# configurations with directories like .git/ excluded
|
||||
"exclude": _str2list,
|
||||
"ignore": And(
|
||||
_str2list,
|
||||
[
|
||||
Coerce(Rule, msg="invalid rule ID"),
|
||||
],
|
||||
),
|
||||
"commands": Coerce(pathlib.Path),
|
||||
"extensions": _str2list,
|
||||
"style_indent": Coerce(
|
||||
lambda v: v if v == "tab" else int(v), msg="expected integer or 'tab'"
|
||||
),
|
||||
"style_line_length": Coerce(int),
|
||||
"style_max_blank_lines": And(
|
||||
Coerce(int),
|
||||
# we could technically support i >= 0, but I think 0 would be a weird
|
||||
# setting and this lets us ignore pluralizing the violation message :)
|
||||
Range(min=1),
|
||||
),
|
||||
"style_indent_namespace_eval": bool,
|
||||
"style_spaces_in_braces": bool,
|
||||
}
|
||||
|
||||
|
||||
def _validate_config(config):
|
||||
"""Validates dictionary read from TOML config file. Individual value validators
|
||||
are implemented in the global dict, this defines the actual structure of the
|
||||
schema."""
|
||||
|
||||
base_config = {
|
||||
Optional("ignore"): _VALIDATORS["ignore"],
|
||||
Optional("commands"): _VALIDATORS["commands"],
|
||||
Optional("style"): {
|
||||
Optional("indent"): _VALIDATORS["style_indent"],
|
||||
Optional("line-length"): _VALIDATORS["style_line_length"],
|
||||
Optional("max-blank-lines"): _VALIDATORS["style_max_blank_lines"],
|
||||
Optional("indent-namespace-eval"): _VALIDATORS[
|
||||
"style_indent_namespace_eval"
|
||||
],
|
||||
Optional("spaces-in-braces"): _VALIDATORS["style_spaces_in_braces"],
|
||||
},
|
||||
}
|
||||
|
||||
schema = Schema(
|
||||
{
|
||||
# exclude and extensions can only be used in global context
|
||||
Optional("exclude"): _VALIDATORS["exclude"],
|
||||
Optional("extensions"): _VALIDATORS["extensions"],
|
||||
**base_config,
|
||||
Optional("fileset"): Schema(
|
||||
[{"paths": [Coerce(pathlib.Path)], **base_config}], required=True
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
return schema(config)
|
||||
except Invalid as e:
|
||||
if not e.path:
|
||||
raise ConfigError(e.error_message)
|
||||
|
||||
# Stringify error path to my own taste.
|
||||
path = []
|
||||
for item in e.path:
|
||||
if isinstance(item, int):
|
||||
# Brackets around indices
|
||||
if len(path) > 0:
|
||||
path[-1] += f"[{item}]"
|
||||
else:
|
||||
path.append(f"[{item}]")
|
||||
else:
|
||||
path.append(str(item))
|
||||
|
||||
raise ConfigError(f"{e.error_message} ({'.'.join(path)})")
|
||||
|
||||
|
||||
def _validator(key):
|
||||
def func(s):
|
||||
try:
|
||||
return Schema(_VALIDATORS[key])(s)
|
||||
except Invalid as e:
|
||||
raise argparse.ArgumentTypeError(str(e))
|
||||
|
||||
return func
|
||||
|
||||
|
||||
def _add_bool(group, parser, dest, yes_flag, no_flag):
|
||||
mutex_group = group.add_mutually_exclusive_group(required=False)
|
||||
mutex_group.add_argument(yes_flag, dest=dest, action="store_true")
|
||||
mutex_group.add_argument(no_flag, dest=dest, action="store_false")
|
||||
parser.set_defaults(**{dest: None})
|
||||
|
||||
|
||||
def setup_common_config_cli_args(config_group):
|
||||
config_group.add_argument(
|
||||
"--exclude", type=_validator("exclude"), metavar='"path1, path2, ..."'
|
||||
)
|
||||
config_group.add_argument(
|
||||
"--extend-exclude", type=_validator("exclude"), metavar='"path1, path2, ..."'
|
||||
)
|
||||
config_group.add_argument(
|
||||
"--extensions", type=_validator("extensions"), metavar='"tcl, xdc, ..."'
|
||||
)
|
||||
config_group.add_argument(
|
||||
"--commands", type=_validator("commands"), metavar="<path>"
|
||||
)
|
||||
|
||||
|
||||
def setup_config_cli_args(parser):
|
||||
"""This method defines config-related CLI arguments.
|
||||
|
||||
The destvars of these switches should match the fields of Config.
|
||||
"""
|
||||
config_group = parser.add_argument_group("configuration arguments")
|
||||
|
||||
config_group.add_argument(
|
||||
"--ignore", type=_validator("ignore"), metavar='"rule1, rule2, ..."'
|
||||
)
|
||||
config_group.add_argument(
|
||||
"--extend-ignore", type=_validator("ignore"), metavar='"rule1, rule2, ..."'
|
||||
)
|
||||
setup_common_config_cli_args(config_group)
|
||||
config_group.add_argument(
|
||||
"--style-line-length",
|
||||
type=_validator("style_line_length"),
|
||||
metavar="<line_length>",
|
||||
)
|
||||
|
||||
|
||||
def setup_tclfmt_config_cli_args(parser):
|
||||
"""This method defines the subset of config-related CLI arguments used by tclfmt.
|
||||
|
||||
The destvars of these switches should match the fields of Config.
|
||||
"""
|
||||
config_group = parser.add_argument_group("configuration arguments")
|
||||
|
||||
setup_common_config_cli_args(config_group)
|
||||
|
||||
config_group.add_argument(
|
||||
"--indent",
|
||||
type=_validator("style_indent"),
|
||||
metavar="<indent>",
|
||||
dest="style_indent",
|
||||
)
|
||||
config_group.add_argument(
|
||||
"--max-blank-lines",
|
||||
type=_validator("style_max_blank_lines"),
|
||||
metavar="<max_blank_lines>",
|
||||
dest="style_max_blank_lines",
|
||||
)
|
||||
_add_bool(
|
||||
config_group,
|
||||
parser,
|
||||
"style_indent_namespace_eval",
|
||||
"--indent-namespace-eval",
|
||||
"--no-indent-namespace-eval",
|
||||
)
|
||||
_add_bool(
|
||||
config_group,
|
||||
parser,
|
||||
"style_spaces_in_braces",
|
||||
"--spaces-in-braces",
|
||||
"--no-spaces-in-braces",
|
||||
)
|
||||
|
||||
|
||||
def _flatten(d, prefix=None):
|
||||
"""Flattens TOML config dictionary structure to match the flat set of fields
|
||||
expected by Config dataclass."""
|
||||
if prefix is None:
|
||||
prefix = []
|
||||
|
||||
flat = {}
|
||||
for k, v in d.items():
|
||||
if isinstance(v, dict):
|
||||
flat.update(_flatten(v, prefix=prefix + [k]))
|
||||
else:
|
||||
flat["_".join(prefix + [k]).replace("-", "_")] = v
|
||||
|
||||
return flat
|
||||
|
||||
|
||||
class RunConfig:
|
||||
"""Class that holds information about both global and fileset configs. User
|
||||
code can get a Config object that applies to a particular file by calling
|
||||
get_from_path() and supplying that file's path."""
|
||||
|
||||
def __init__(self, global_config=None, fileset_configs=None):
|
||||
if global_config is not None:
|
||||
self._global_config = global_config
|
||||
else:
|
||||
self._global_config = Config()
|
||||
|
||||
self._fileset_configs = [
|
||||
# ([pathlib.Path...], Config])
|
||||
]
|
||||
if fileset_configs is not None:
|
||||
self._fileset_configs = fileset_configs
|
||||
|
||||
@property
|
||||
def exclude(self):
|
||||
return self._global_config.exclude
|
||||
|
||||
@property
|
||||
def extensions(self):
|
||||
return self._global_config.extensions
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config_dict: dict, root: pathlib.Path):
|
||||
config_dict = _validate_config(config_dict)
|
||||
try:
|
||||
fileset_config_dicts = config_dict.pop("fileset")
|
||||
except KeyError:
|
||||
fileset_config_dicts = []
|
||||
|
||||
config_dict = _flatten(config_dict)
|
||||
global_config = Config(**config_dict)
|
||||
|
||||
fileset_configs = []
|
||||
for fileset_config in fileset_config_dicts:
|
||||
paths = []
|
||||
for path in fileset_config.pop("paths"):
|
||||
if not path.is_absolute():
|
||||
path = root / path
|
||||
paths.append(path.resolve())
|
||||
|
||||
fileset_config = _flatten(fileset_config)
|
||||
|
||||
# pull in default values from global config
|
||||
full_fileset_config = config_dict.copy()
|
||||
full_fileset_config.update(fileset_config)
|
||||
|
||||
fileset_configs.append((paths, Config(**full_fileset_config)))
|
||||
|
||||
return cls(global_config, fileset_configs)
|
||||
|
||||
@classmethod
|
||||
def from_path(cls, path: Union[str, pathlib.Path], root: pathlib.Path):
|
||||
path = pathlib.Path(path)
|
||||
|
||||
if not path.exists():
|
||||
raise FileNotFoundError
|
||||
|
||||
with open(path, "rb") as f:
|
||||
try:
|
||||
data = tomllib.load(f)
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
raise ConfigError(f"{path}: {e}")
|
||||
|
||||
try:
|
||||
return cls.from_dict(data, root)
|
||||
except ConfigError as e:
|
||||
raise ConfigError(f"{path}: {e}")
|
||||
|
||||
@classmethod
|
||||
def from_pyproject(cls, directory=None):
|
||||
if directory is None:
|
||||
directory = pathlib.Path(".")
|
||||
else:
|
||||
directory = pathlib.Path(directory)
|
||||
|
||||
path = directory / "pyproject.toml"
|
||||
|
||||
if not path.exists():
|
||||
raise FileNotFoundError
|
||||
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
tclint_config = data.get("tool", {})["tclint"]
|
||||
|
||||
try:
|
||||
return cls.from_dict(tclint_config, directory)
|
||||
except ConfigError as e:
|
||||
raise ConfigError(f"pyproject.toml: {e}")
|
||||
|
||||
def get_for_path(self, path) -> Config:
|
||||
if path is None:
|
||||
return self._global_config
|
||||
|
||||
path = path.resolve()
|
||||
for fileset_paths, config in self._fileset_configs:
|
||||
for fileset_path in fileset_paths:
|
||||
if path.is_relative_to(fileset_path):
|
||||
return config
|
||||
|
||||
return self._global_config
|
||||
|
||||
def apply_cli_args(self, args):
|
||||
self._global_config.apply_cli_args(args)
|
||||
for _, fileset_config in self._fileset_configs:
|
||||
fileset_config.apply_cli_args(args)
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
DEFAULT_CONFIGS = ("tclint.toml", ".tclint")
|
||||
|
||||
|
||||
def get_config(
|
||||
config_path: OptionalType[pathlib.Path], root: pathlib.Path
|
||||
) -> OptionalType[RunConfig]:
|
||||
"""Loads a config file.
|
||||
|
||||
If `config_path` is supplied, attempts to read config file from this path. If the
|
||||
path can't be found, raises a ConfigError.
|
||||
|
||||
Otherwise, attempts to read config from `root`/{tclint.toml, .tclint,
|
||||
pyproject.toml} (in that order). If none of these files can be found, returns None.
|
||||
|
||||
`root` is also used to resolve some relative paths in the config file.
|
||||
"""
|
||||
# user-supplied
|
||||
if config_path is not None:
|
||||
try:
|
||||
return RunConfig.from_path(config_path, root)
|
||||
except FileNotFoundError:
|
||||
raise ConfigError(f"path {config_path} doesn't exist")
|
||||
|
||||
for path in DEFAULT_CONFIGS:
|
||||
try:
|
||||
return RunConfig.from_path(root / path, root)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return RunConfig.from_pyproject(directory=root)
|
||||
except ConfigError as e:
|
||||
raise e
|
||||
except (FileNotFoundError, tomllib.TOMLDecodeError, KeyError):
|
||||
# just skip if file doesn't exist, contains TOML errors, or tclint key not found
|
||||
pass
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user