add updates libs

This commit is contained in:
Christoph Brandau
2026-06-18 17:21:58 +02:00
parent 91ce762570
commit 41f331d4c4
150 changed files with 8249 additions and 2550 deletions
+352 -297
View File
@@ -1,20 +1,38 @@
import argparse
import pathlib
from typing import Union, List
from typing import Optional as OptionalType
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 Schema, Optional, And, Coerce, Invalid, Range
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
@@ -24,17 +42,20 @@ class Config:
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(
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[Union[str, int]] = dataclasses.field(default=None)
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: bool = dataclasses.field(default=False)
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)
@@ -63,273 +84,42 @@ class Config:
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
# 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
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)
try:
fileset_config_dicts = config_dict.pop("fileset")
except KeyError:
fileset_config_dicts = []
config_dict = _validate_config(config_dict, root)
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)
return cls(**config_dict)
@classmethod
def from_path(cls, path: Union[str, pathlib.Path], root: pathlib.Path):
def from_path(cls, path: str | pathlib.Path, root: pathlib.Path):
path = pathlib.Path(path)
if not path.exists():
if not path.exists() or path.is_dir():
raise FileNotFoundError
with open(path, "rb") as f:
@@ -365,22 +155,306 @@ class RunConfig:
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
# Validators using `voluptuous` library that check and normalize config inputs.
# Used for checking both config files as well as config-related CLI args.
return self._global_config
# 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 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)
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):
@@ -390,34 +464,15 @@ class ConfigError(Exception):
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")
def load_config_at(directory: pathlib.Path) -> OptionalType[Config]:
for path in DEFAULT_CONFIGS:
try:
return RunConfig.from_path(root / path, root)
return Config.from_path(directory / path, directory)
except FileNotFoundError:
pass
try:
return RunConfig.from_pyproject(directory=root)
return Config.from_pyproject(directory=directory)
except ConfigError as e:
raise e
except (FileNotFoundError, tomllib.TOMLDecodeError, KeyError):