Files
2026-06-18 17:21:58 +02:00

483 lines
15 KiB
Python

import argparse
import dataclasses
import pathlib
import sys
from enum import IntEnum
from typing import Callable, NamedTuple
from typing import Optional as OptionalType
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
from voluptuous import And, Coerce, Invalid, Optional, Range, Schema
from tclint.violations import Rule
class SpacesInBraces(IntEnum):
"""Enum listing valid values for --spaces-in-braces."""
NEVER = 0
ALWAYS = 1
BALANCED_NO = 2
BALANCED_YES = 3
class ExcludePattern(NamedTuple):
"""Exclude patterns are applied relative to a certain root. This dataclass is used
to bundle the two."""
pattern: str
root: pathlib.Path
@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[ExcludePattern] = dataclasses.field(default_factory=list)
ignore: list[Rule] = dataclasses.field(default_factory=list)
commands: list[str | pathlib.Path] = dataclasses.field(default_factory=list)
extensions: list[str] = dataclasses.field(
default_factory=lambda: ["tcl", "sdc", "xdc", "upf"]
)
style_indent: OptionalType[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: SpacesInBraces = dataclasses.field(
default=SpacesInBraces.NEVER
)
style_emacs: 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
elif isinstance(self.style_indent, tuple):
return " " * self.style_indent[0]
# Should be unreachable, validated on ingestion of config
raise ValueError(
f"unexpected value for config.style_indent: {self.style_indent}"
)
def get_indent_mixed_tab_size(self) -> int:
if isinstance(self.style_indent, tuple):
return self.style_indent[1]
return 0
def diff(self) -> str:
"""Return string representation of Config only showing fields that differ from
default instance."""
default_config = Config()
values = []
for field in dataclasses.fields(self):
value = getattr(self, field.name)
default = getattr(default_config, field.name)
if value != default:
values.append(f"{field.name}={value}")
return f"Config({', '.join(values)})"
@classmethod
def from_dict(cls, config_dict: dict, root: pathlib.Path):
config_dict = _validate_config(config_dict, root)
config_dict = _flatten(config_dict)
return cls(**config_dict)
@classmethod
def from_path(cls, path: str | pathlib.Path, root: pathlib.Path):
path = pathlib.Path(path)
if not path.exists() or path.is_dir():
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}")
# 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
def _add_root(root: pathlib.Path) -> Callable[[pathlib.Path], pathlib.Path]:
"""Resolve path relative to `root.`"""
def _path(path: pathlib.Path) -> pathlib.Path:
path = path.expanduser()
if not path.is_absolute():
path = root / path
return path
return _path
def parse_mixed(v: str) -> tuple[int, int]:
"""Parse --indent=mixed,<s>,<t>."""
s = v.split(",")
if not (len(s) == 3 and s[0] == "mixed" and s[1].isdigit() and s[2].isdigit()):
raise ValueError()
return (int(s[1]), int(s[2]))
# Define validators as module variables so they can be reused for config file schema
# validation and CLI argument parsing.
def _validate_exclude(root):
"""Along with parsing the list, bundles exclude patterns with their root."""
return And(_str2list, [lambda p: ExcludePattern(p, root)])
_validate_ignore = And(
_str2list,
[
Coerce(Rule, msg="invalid rule ID"),
],
)
def _validate_commands(root):
add_root = _add_root(root)
def _process(command: str):
path = add_root(pathlib.Path(command))
if path.exists():
return path
return command
return And(_str2list, [_process])
_validate_extensions = _str2list
_validate_style_indent = Coerce(
lambda v: (
v
if v == "tab"
else (
int(v)
if isinstance(v, int) or (isinstance(v, str) and v.isdigit())
else parse_mixed(v)
)
),
msg="expected integer, 'tab', or 'mixed',integer,integer",
)
_validate_style_line_length = Coerce(int)
_validate_style_max_blank_lines = And(
Coerce(int),
Range(min=1),
)
_validate_style_indent_namespace_eval = bool
_validate_style_emacs = bool
def parse_spaces_in_braces(v: str | bool) -> SpacesInBraces:
if isinstance(v, bool):
# Handle spaces-in-braces = true/false in config file.
return SpacesInBraces.ALWAYS if v else SpacesInBraces.NEVER
if v == "never":
return SpacesInBraces.NEVER
if v == "always":
return SpacesInBraces.ALWAYS
if v == "balanced-no":
return SpacesInBraces.BALANCED_NO
if v == "balanced-yes":
return SpacesInBraces.BALANCED_YES
raise ValueError()
_validate_style_spaces_in_braces = Coerce(
lambda v: (parse_spaces_in_braces(v)),
msg="always, never, balanced-yes, or balanced-no",
)
_validate_style_no_spaces_in_braces = Coerce(lambda v: (v))
def _validate_config(config: dict, root: pathlib.Path):
"""Validates dictionary read from TOML config file. Individual value validators
are implemented in the module-level variables above; this defines the actual
structure of the schema.
root is used to resolve values that may be relative to a certain path.
"""
schema = Schema({
Optional("exclude"): _validate_exclude(root),
Optional("extensions"): _validate_extensions,
Optional("ignore"): _validate_ignore,
Optional("commands"): _validate_commands(root),
Optional("style"): {
Optional("indent"): _validate_style_indent,
Optional("line-length"): _validate_style_line_length,
Optional("max-blank-lines"): _validate_style_max_blank_lines,
Optional("indent-namespace-eval"): _validate_style_indent_namespace_eval,
Optional("spaces-in-braces"): _validate_style_spaces_in_braces,
Optional("emacs"): _validate_style_emacs,
},
})
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: list[str] = []
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 _argparsify(validator: Callable) -> Callable:
"""Wrapper that applies a voluptuous-style validator and is compatible with
argparse's type argument."""
def func(s):
try:
return Schema(validator)(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, cwd: pathlib.Path):
"""
This method defines config-related CLI arguments common to both tclint and tclfmt.
The destvars of these switches should match the fields of Config.
Relative paths and exclude patterns will be resolved relative to `cwd`. It may seem
weird to specify this in a "setup" function (as opposed to Config.apply_cli_args),
but the paths are resolved by the validator functions configured here. For our use
case, this is fine since the setup and application of the CLI args are close
together in the application code.
"""
config_group.add_argument(
"--trust-plugins",
action="store_true",
help="enables execution of uninstalled Python-based command plugins",
)
config_group.add_argument(
"--exclude",
type=_argparsify(_validate_exclude(cwd)),
metavar='"path1, path2, ..."',
)
config_group.add_argument(
"--extend-exclude",
type=_argparsify(_validate_exclude(cwd)),
metavar='"path1, path2, ..."',
)
config_group.add_argument(
"--extensions",
type=_argparsify(_validate_extensions),
metavar='"tcl, xdc, ..."',
)
config_group.add_argument(
"--commands", type=_argparsify(_validate_commands(cwd)), metavar="<path>"
)
def setup_config_cli_args(parser, cwd: pathlib.Path):
"""This method defines config-related CLI arguments used by tclint.
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=_argparsify(_validate_ignore), metavar='"rule1, rule2, ..."'
)
config_group.add_argument(
"--extend-ignore",
type=_argparsify(_validate_ignore),
metavar='"rule1, rule2, ..."',
)
setup_common_config_cli_args(config_group, cwd)
config_group.add_argument(
"--style-line-length",
type=_argparsify(_validate_style_line_length),
metavar="<line_length>",
)
def setup_tclfmt_config_cli_args(parser, cwd: pathlib.Path):
"""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, cwd)
config_group.add_argument(
"--indent",
type=_argparsify(_validate_style_indent),
metavar="<indent>",
dest="style_indent",
)
config_group.add_argument(
"--max-blank-lines",
type=_argparsify(_validate_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",
)
config_group.add_argument(
"--spaces-in-braces",
type=_argparsify(_validate_style_spaces_in_braces),
metavar="<always|never|balanced-yes|balanced-no>",
dest="style_spaces_in_braces",
)
# Alias for --spaces-in-braces never.
config_group.add_argument(
"--no-spaces-in-braces",
action="store_const",
const=SpacesInBraces.NEVER,
dest="style_spaces_in_braces",
)
_add_bool(
config_group,
parser,
"style_emacs",
"--emacs",
"--no-emacs",
)
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 ConfigError(Exception):
pass
DEFAULT_CONFIGS = ("tclint.toml", ".tclint")
def load_config_at(directory: pathlib.Path) -> OptionalType[Config]:
for path in DEFAULT_CONFIGS:
try:
return Config.from_path(directory / path, directory)
except FileNotFoundError:
pass
try:
return Config.from_pyproject(directory=directory)
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