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
+98
View File
@@ -0,0 +1,98 @@
import os
from pathlib import Path
from typing import Optional
from tclint.cli.utils import make_exclude_filter
from tclint.config import Config, load_config_at
class Resolver:
def __init__(self, cli_args=None, global_config: Optional[Config] = None):
# TODO: Figure out a way to annotate cli_args type effectively.
self._config_cache: dict[Path, Config] = {}
self._global_config = global_config
# Instantiate and hang on to a default config so the Resolver can cache many
# instances of it without blowing up memory footprint. (This has not been
# empirically validated!)
self._default_config = Config()
self._cli_args = None
if cli_args is not None:
self._default_config.apply_cli_args(cli_args)
self._cli_args = cli_args
def _find_config(self, directory: Path) -> Config:
config = load_config_at(directory)
if config is not None:
if self._cli_args is not None:
config.apply_cli_args(self._cli_args)
return config
# We're at the root, bail!
if directory.parent == directory:
return self._default_config
return self.find_config(directory.parent)
def find_config(self, directory: Path) -> Config:
if self._global_config is not None:
return self._global_config
if directory in self._config_cache:
return self._config_cache[directory]
config = self._find_config(directory)
self._config_cache[directory] = config
return config
def resolve_sources(
self, paths: list[Path], cwd: Path
) -> list[tuple[Optional[Path], Config]]:
sources: list[tuple[Optional[Path], Config]] = []
for path in paths:
if str(path) == "-":
config = self.find_config(cwd)
sources.append((None, config))
continue
if not path.exists():
raise FileNotFoundError(f"path {path} does not exist")
# We need to slap a .resolve() on the find_config()'s to make sure the
# method traverses upwards to the FS root. It would probably ideal to just
# resolve each path in the main body of the loop, but this actually has
# implications on how the filepath is printed out (it becomes an absolute
# path, symlinks are resolved).
if not path.is_dir():
config = self.find_config(path.resolve().parent)
is_excluded = make_exclude_filter(config.exclude)
if is_excluded(path):
continue
sources.append((path, config))
continue
for dirstr, dirs, filenames in os.walk(path):
dirpath = Path(dirstr)
config = self.find_config(dirpath.resolve())
is_excluded = make_exclude_filter(config.exclude)
extensions = [
f".{ext}" if not ext.startswith(".") else ext
for ext in config.extensions
]
# Update dirs to prune next directories to traverse based on exclude.
to_traverse = []
for dir in dirs:
if not is_excluded(Path(dir)):
to_traverse.append(dir)
dirs[:] = to_traverse
for name in filenames:
_, ext = os.path.splitext(name)
if ext.lower() in extensions:
child = dirpath / name
if not is_excluded(child):
sources.append((child, config))
return sources
+78 -36
View File
@@ -4,16 +4,17 @@ import argparse
import pathlib
import sys
from tclint.cli.utils import resolve_sources, register_codec_warning
from tclint.cli.resolver import Resolver
from tclint.cli.utils import register_codec_warning
from tclint.commands.plugins import PluginManager
from tclint.config import (
get_config,
setup_tclfmt_config_cli_args,
Config,
ConfigError,
RunConfig,
SpacesInBraces,
setup_tclfmt_config_cli_args,
)
from tclint.parser import Parser, TclSyntaxError
from tclint.format import Formatter, FormatterOpts
from tclint.parser import Parser, TclSyntaxError
try:
from tclint._version import __version__ # type: ignore
@@ -27,22 +28,41 @@ EXIT_SYNTAX_ERROR = 2
EXIT_INPUT_ERROR = 4
def format(script: str, config: Config, debug=False) -> str:
plugins = [config.commands] if config.commands is not None else []
parser = Parser(debug=debug, command_plugins=plugins)
def format(
script: str,
config: Config,
plugins: PluginManager,
debug=False,
debug_whitespace=False,
partial=False,
) -> str:
parser = Parser(debug=debug, commands=plugins.get_commands(config.commands))
formatter = Formatter(
FormatterOpts(
indent=config.get_indent(),
spaces_in_braces=config.style_spaces_in_braces,
indent_mixed_tab_size=config.get_indent_mixed_tab_size(),
spaces_in_braces=(
config.style_spaces_in_braces == SpacesInBraces.ALWAYS
or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
),
balanced_spaces_in_braces=(
config.style_spaces_in_braces == SpacesInBraces.BALANCED_NO
or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
),
max_blank_lines=config.style_max_blank_lines,
indent_namespace_eval=config.style_indent_namespace_eval,
emacs=config.style_emacs,
debug_whitespace=debug_whitespace,
)
)
return formatter.format_top(script, parser)
if partial:
return formatter.format_partial(script, parser)
else:
return formatter.format_top(script, parser)
def check(path: pathlib.Path, script: str, formatted: str):
def check(path: str, script: str, formatted: str):
parser = Parser()
original_tree = parser.parse(script)
formatted_tree = parser.parse(formatted)
@@ -86,6 +106,12 @@ def main():
" of output (e.g. -dd)"
),
)
parser.add_argument(
"--debug-whitespace",
action="store_true",
default=False,
help="display whitespace in debug mode.",
)
parser.add_argument(
"-c",
"--config",
@@ -94,41 +120,46 @@ def main():
default=None,
metavar="<path>",
)
setup_tclfmt_config_cli_args(parser)
parser.add_argument(
"--partial",
help="treat input as a fragment of a script",
action="store_true",
)
cwd = pathlib.Path.cwd()
setup_tclfmt_config_cli_args(parser, cwd)
args = parser.parse_args()
global_config = None
if args.config is not None:
try:
global_config = Config.from_path(args.config, cwd)
global_config.apply_cli_args(args)
except FileNotFoundError:
print(f"Config file path doesn't exist: {args.config}")
return EXIT_INPUT_ERROR
except ConfigError as e:
print(f"Invalid config file: {e}")
return EXIT_INPUT_ERROR
resolver = Resolver(args, global_config)
try:
config = get_config(args.config, pathlib.Path.cwd())
sources = resolver.resolve_sources(args.source, cwd)
except FileNotFoundError as e:
print(f"Invalid path provided: {e}")
return EXIT_INPUT_ERROR
except ConfigError as e:
print(f"Invalid config file: {e}")
return EXIT_INPUT_ERROR
if config is None:
config = RunConfig()
config.apply_cli_args(args)
try:
# TODO: we should eventually allow tclfmt to find a config by walking up
# directories, at which point exclude_root should be the parent dir of
# the config file, unless -c is used (eslint rules)
exclude_root = pathlib.Path.cwd()
sources = resolve_sources(
args.source,
exclude_patterns=config.exclude,
exclude_root=exclude_root,
extensions=config.extensions,
)
except FileNotFoundError as e:
print(f"Invalid path provided: {e}")
return EXIT_INPUT_ERROR
plugin_manager = PluginManager(trust_uninstalled=args.trust_plugins)
retcode = EXIT_OK
register_codec_warning("replace_with_warning")
reformat_count = 0
for path in sources:
for path, config in sources:
if path is None:
script = sys.stdin.read()
out_prefix = "(stdin)"
@@ -139,7 +170,12 @@ def main():
try:
formatted = format(
script, config.get_for_path(path), debug=(args.debug > 1)
script,
config,
plugin_manager,
debug=(args.debug > 1),
debug_whitespace=args.debug_whitespace,
partial=args.partial,
)
if args.in_place and path:
with open(path, "w") as f:
@@ -155,9 +191,15 @@ def main():
print(formatted, end="")
if args.debug > 0:
check(path, script, formatted)
if args.debug_whitespace:
print(
"Warning: --debug-whitespace enabled, disabling original vs."
" formatted syntax tree check"
)
else:
check(out_prefix, script, formatted)
except TclSyntaxError as e:
line, col = e.pos
line, col = e.start
print(f"{out_prefix}:{line}:{col}: syntax error: {e}", file=sys.stderr)
retcode |= EXIT_SYNTAX_ERROR
continue
+39 -43
View File
@@ -3,21 +3,16 @@
import argparse
import pathlib
import sys
from typing import Dict, List, Optional
from typing import Optional
from tclint.config import (
get_config,
setup_config_cli_args,
Config,
ConfigError,
RunConfig,
)
from tclint.parser import Parser, TclSyntaxError
from tclint.checks import get_checkers
from tclint.violations import Violation, Rule
from tclint.cli.resolver import Resolver
from tclint.cli.utils import register_codec_warning
from tclint.commands.plugins import PluginManager
from tclint.comments import CommentVisitor
from tclint.cli.utils import resolve_sources, register_codec_warning
from tclint.config import Config, ConfigError, setup_config_cli_args
from tclint.parser import Parser, TclSyntaxError
from tclint.violations import Rule, Violation
try:
from tclint._version import __version__ # type: ignore
@@ -32,10 +27,10 @@ EXIT_INPUT_ERROR = 4
def filter_violations(
violations: List[Violation],
config_ignore: List[Rule],
inline_ignore: Dict[int, List[Rule]],
) -> List[Violation]:
violations: list[Violation],
config_ignore: list[Rule],
inline_ignore: dict[int, list[Rule]],
) -> list[Violation]:
filtered_violations = []
for violation in violations:
@@ -53,11 +48,11 @@ def filter_violations(
def lint(
script: str,
config: Config,
plugins: PluginManager,
path: Optional[pathlib.Path],
debug=0,
) -> List[Violation]:
plugins = [config.commands] if config.commands is not None else []
parser = Parser(debug=(debug > 0), command_plugins=plugins)
) -> list[Violation]:
parser = Parser(debug=(debug > 0), commands=plugins.get_commands(config.commands))
violations = []
tree = parser.parse(script)
@@ -66,7 +61,7 @@ def lint(
if debug > 0:
print(tree.pretty(positions=(debug > 1)))
for checker in get_checkers():
for checker in get_checkers(plugins):
violations += checker.check(script, tree, config)
v = CommentVisitor()
@@ -105,40 +100,40 @@ def main():
default=None,
metavar="<path>",
)
setup_config_cli_args(parser)
cwd = pathlib.Path.cwd()
setup_config_cli_args(parser, cwd)
args = parser.parse_args()
global_config = None
if args.config is not None:
try:
global_config = Config.from_path(args.config, cwd)
global_config.apply_cli_args(args)
except FileNotFoundError:
print(f"Config file path doesn't exist: {args.config}")
return EXIT_INPUT_ERROR
except ConfigError as e:
print(f"Invalid config file: {e}")
return EXIT_INPUT_ERROR
resolver = Resolver(args, global_config)
try:
config = get_config(args.config, pathlib.Path())
sources = resolver.resolve_sources(args.source, cwd)
except FileNotFoundError as e:
print(f"Invalid path provided: {e}")
return EXIT_INPUT_ERROR
except ConfigError as e:
print(f"Invalid config file: {e}")
return EXIT_INPUT_ERROR
if config is None:
config = RunConfig()
config.apply_cli_args(args)
try:
# TODO: we should eventually allow tclint to find a config by walking up
# directories, at which point exclude_root should be the parent dir of
# the config file, unless -c is used (eslint rules)
exclude_root = pathlib.Path.cwd()
sources = resolve_sources(
args.source,
exclude_patterns=config.exclude,
exclude_root=exclude_root,
extensions=config.extensions,
)
except FileNotFoundError as e:
print(f"Invalid path provided: {e}")
return EXIT_INPUT_ERROR
plugin_manager = PluginManager(trust_uninstalled=args.trust_plugins)
retcode = EXIT_OK
register_codec_warning("replace_with_warning")
for path in sources:
for path, config in sources:
if path is None:
script = sys.stdin.read()
out_prefix = "(stdin)"
@@ -150,7 +145,8 @@ def main():
try:
violations = lint(
script,
config.get_for_path(path),
config,
plugin_manager,
path,
debug=args.debug,
)
+149 -61
View File
@@ -1,22 +1,27 @@
import argparse
import dataclasses
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import uuid
from pathlib import Path
from typing import Optional
from lsprotocol import types as lsp
from pygls.server import LanguageServer
from pygls.workspace import TextDocument
from pygls.uris import to_fs_path
from pygls.workspace import TextDocument
from tclint.cli import tclint
from tclint.config import get_config, DEFAULT_CONFIGS, RunConfig, Config, ConfigError
from tclint.cli import tclint, utils
from tclint.commands.plugins import PluginManager
from tclint.config import (
DEFAULT_CONFIGS,
Config,
ConfigError,
SpacesInBraces,
load_config_at,
)
from tclint.format import Formatter, FormatterOpts
from tclint.lexer import TclSyntaxError
from tclint.parser import Parser
from tclint.cli import utils
try:
from tclint._version import __version__ # type: ignore
@@ -25,13 +30,14 @@ except ModuleNotFoundError:
DIAGNOSTIC_SOURCE = "tclint"
_DEFAULT_CONFIG = Config()
def lint(source, config, path):
def lint(source, config, plugin_manager, path):
diagnostics = []
try:
violations = tclint.lint(source, config, path)
violations = tclint.lint(source, config, plugin_manager, path)
except TclSyntaxError as e:
return [
lsp.Diagnostic(
@@ -62,7 +68,7 @@ def lint(source, config, path):
start=start,
end=end,
),
code=violation.id,
code=str(violation.id),
source=DIAGNOSTIC_SOURCE,
)
)
@@ -83,15 +89,26 @@ class TclspServer(LanguageServer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.diagnostics = {}
self.global_config: RunConfig = None
# Maps workspace roots to configs.
self.configs: Dict[Path, RunConfig] = {}
# There are many config caches!!
# Caches loaded config files specified in LSP settings.
self.workspace_configs: dict[Path, Config] = {}
# Caches loaded config files present in open workspaces.
self.config_files: dict[Path, Config] = {}
# Caches which config is used by each open file.
self.source_configs: dict[Path, Config] = {}
# Tracks which invalid configs we've already displayed an error for, to avoid
# spam.
self.invalid_configs: set[Path] = set()
self.client_supports_refresh = False
self.global_settings = ExtensionSettings()
self.workspace_settings: Dict[Path, ExtensionSettings] = {}
self.workspace_settings: dict[Path, ExtensionSettings] = {}
def get_roots(self) -> List[Path]:
self.plugin_manager = PluginManager()
def get_roots(self) -> list[Path]:
"""Returns root folders currently open in the workspace."""
roots = []
for uri in self.workspace.folders.keys():
@@ -108,10 +125,8 @@ class TclspServer(LanguageServer):
return roots
def get_root(self, path: Path) -> Optional[Path]:
"""Returns workspace root folder that's closest to path.
Returns None if path is not in a workspace folder or if there are no workspace
folders.
"""Returns workspace root if path is in a workspace folder. Otherwise, returns
None.
"""
roots = self.get_roots()
closest_root = None
@@ -124,55 +139,98 @@ class TclspServer(LanguageServer):
if len(relpath.parts) < distance:
distance = len(relpath.parts)
closest_root = root
return closest_root
def get_config_file(self, workspace_root: Path) -> Optional[Path]:
if workspace_root in self.workspace_settings:
settings = self.workspace_settings[workspace_root]
def get_config_file(self, root: Path) -> Optional[Path]:
if root in self.workspace_settings:
settings = self.workspace_settings[root]
return settings.config_file
return self.global_settings.config_file
def load_configs(self):
self.configs = {}
def show_config_error(self, msg: str, path: Path):
if path not in self.invalid_configs:
self.show_message(f"Error loading config file: {msg}")
self.invalid_configs.add(path)
def load_config(self, path: Path, root: Path) -> Optional[Config]:
try:
return Config.from_path(path, root)
except FileNotFoundError:
self.show_config_error(f"{path} doesn't exist", path)
return None
except ConfigError as e:
self.show_config_error(str(e), path)
return None
def load_workspace_setting_configs(self):
"""These may be used a lot if specified, so cache specially."""
for root in self.get_roots():
try:
path = self.get_config_file(root)
config = get_config(path, root)
path = self.get_config_file(root)
if path is None:
continue
config = self.load_config(path, root)
if config is None:
continue
self.workspace_configs[root] = config
def _get_config(self, path: Path) -> Config:
workspace_root = self.get_root(path)
# First, check for configs specified in the LSP settings.
# If not in a workspace, our only shot is to use a global config. Otherwise, we
# bail (no searching, since the LSP only searches up to the workspace root).
if workspace_root is None:
global_file = self.global_settings.config_file
if global_file is not None:
config = self.load_config(global_file, path.parent)
if config is not None:
self.configs[root] = config
except ConfigError as e:
self.show_message(f"Error loading config file: {e}")
return config
return _DEFAULT_CONFIG
# If a global config file exists, we apply it to any file not under a workspace
# folder.
global_path = self.global_settings.config_file
if global_path is not None:
# If file is in a workspace, and we've got a workspace config configured, use
# that (this logic also handles global configs, since these are still
# instantiated once per workspace to resolve relative paths).
if workspace_root is not None and workspace_root in self.workspace_configs:
return self.workspace_configs[workspace_root]
# Otherwise, walk upwards until root.
# path is a file, which is a sneaky trick to guarantee we always run the first
# iteration. It becomes a directory after the first statement in the loop.
while path != workspace_root:
path = path.parent
try:
config = get_config(global_path, global_path.parent)
self.global_config = config
config = load_config_at(path)
except ConfigError as e:
self.show_message(f"Error loading config file: {e}")
self.show_config_error(str(e), path)
return _DEFAULT_CONFIG
def get_config(self, path: Path, root: Optional[Path]) -> Config:
if root in self.configs:
return self.configs[root].get_for_path(path)
if self.global_config is not None:
return self.global_config.get_for_path(path)
return Config()
if config is not None:
return config
def _compute_diagnostics(self, document: TextDocument) -> List[lsp.Diagnostic]:
return _DEFAULT_CONFIG
def get_config(self, path: Path) -> Config:
"""Return config object for a given path.
If no config has already been loaded for root (either by calling this function
or load_configs), this function will search for and load a config file if found.
"""
if path in self.source_configs:
return self.source_configs[path]
config = self._get_config(path)
self.source_configs[path] = config
return config
def _compute_diagnostics(self, document: TextDocument) -> list[lsp.Diagnostic]:
path = Path(document.path)
root = self.get_root(path)
config = self.get_config(path, root)
if root is None:
root = path.parent
config = self.get_config(path)
is_excluded = utils.make_exclude_filter(config.exclude)
if is_excluded(path, root):
if is_excluded(path):
return []
return lint(document.source, config, path)
return lint(document.source, config, self.plugin_manager, path)
def compute_diagnostics(self, document: TextDocument):
# `None` sentinel ensures that `diagnostics` gets updated if the URI is not
@@ -189,11 +247,10 @@ class TclspServer(LanguageServer):
self,
document: TextDocument,
options: lsp.FormattingOptions,
range: Optional[Tuple[int, int]] = None,
range: Optional[tuple[int, int]] = None,
):
path = Path(document.path)
root = self.get_root(path)
config = self.get_config(path, root)
config = self.get_config(path)
parser = Parser()
@@ -205,9 +262,19 @@ class TclspServer(LanguageServer):
formatter = Formatter(
FormatterOpts(
indent=indent,
spaces_in_braces=config.style_spaces_in_braces,
indent_mixed_tab_size=config.get_indent_mixed_tab_size(),
spaces_in_braces=(
config.style_spaces_in_braces == SpacesInBraces.ALWAYS
or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
),
balanced_spaces_in_braces=(
config.style_spaces_in_braces == SpacesInBraces.BALANCED_NO
or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
),
max_blank_lines=config.style_max_blank_lines,
indent_namespace_eval=config.style_indent_namespace_eval,
emacs=False,
debug_whitespace=False,
)
)
@@ -230,13 +297,30 @@ def did_open(ls: TclspServer, params: lsp.DidOpenTextDocumentParams):
@server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
def did_change(ls: TclspServer, params: lsp.DidOpenTextDocumentParams):
def did_change(ls: TclspServer, params: lsp.DidChangeTextDocumentParams):
"""Parse each document when it is changed"""
logging.debug("Received %s: %s", lsp.TEXT_DOCUMENT_DID_CHANGE, params)
doc = ls.workspace.get_text_document(params.text_document.uri)
ls.compute_diagnostics(doc)
@server.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
def did_close(ls: TclspServer, params: lsp.DidCloseTextDocumentParams):
"""Free up resources when a document is closed."""
logging.debug("Received %s: %s", lsp.TEXT_DOCUMENT_DID_CLOSE, params)
doc = ls.workspace.get_text_document(params.text_document.uri)
try:
del ls.diagnostics[doc.uri]
except KeyError:
pass
try:
del ls.source_configs[Path(doc.path)]
except KeyError:
pass
@server.feature(
lsp.TEXT_DOCUMENT_DIAGNOSTIC,
lsp.DiagnosticOptions(
@@ -275,7 +359,11 @@ def change_watched_files(ls: TclspServer, params: lsp.DidChangeWatchedFilesParam
# Clear diagnostics cache so they get recalculated when requested
ls.diagnostics = {}
ls.load_configs()
# Config files changed, clear the many caches!
ls.config_files = {}
ls.source_configs = {}
ls.invalid_configs = set()
if ls.client_supports_refresh:
ls.lsp.send_request(lsp.WORKSPACE_DIAGNOSTIC_REFRESH, None)
@@ -369,7 +457,7 @@ def init(ls: TclspServer, params: lsp.InitializeParams):
capabilities = ls.client_capabilities.workspace
try:
ls.client_supports_refresh = (
ls.client_supports_refresh = bool(
capabilities.diagnostics.refresh_support # type: ignore[union-attr]
)
except AttributeError:
@@ -391,7 +479,7 @@ def init(ls: TclspServer, params: lsp.InitializeParams):
for settings in (ls.global_settings, *ls.workspace_settings.values()):
if settings.config_file is not None:
watchers.append(
lsp.FileSystemWatcher(glob_pattern=settings.config_file)
lsp.FileSystemWatcher(glob_pattern=str(settings.config_file))
)
ls.register_capability(
@@ -408,7 +496,7 @@ def init(ls: TclspServer, params: lsp.InitializeParams):
)
)
ls.load_configs()
ls.load_workspace_setting_configs()
def main():
+33 -65
View File
@@ -1,11 +1,14 @@
import codecs
import os
import pathlib
import re
from typing import List, Optional
from collections import defaultdict
from pathlib import Path
from typing import Callable
import pathspec
from tclint.config import ExcludePattern
def register_codec_warning(name):
def replace_with_warning_handler(e):
@@ -16,73 +19,38 @@ def register_codec_warning(name):
codecs.register_error(name, replace_with_warning_handler)
def make_exclude_filter(exclude_patterns: List[str]):
exclude_patterns = [
re.sub(r"^\s*#", r"\#", pattern) for pattern in exclude_patterns
]
exclude_spec = pathspec.PathSpec.from_lines("gitwildmatch", exclude_patterns)
def make_exclude_filter(
exclude_patterns: list[ExcludePattern],
) -> Callable[[Path], bool]:
# Transform patterns into a data structure keyed on root.
patterns_by_root = defaultdict(list)
for pattern, root in exclude_patterns:
# I think this is escaping #, which would otherwise be treated like a comment.
# Not 100% sure though, I originally wrote this a while ago.
pattern = re.sub(r"^\s*#", r"\#", pattern)
patterns_by_root[root.resolve()].append(pattern)
def is_excluded(path: pathlib.Path, root: pathlib.Path) -> bool:
compiled_patterns = {}
for root in patterns_by_root.keys():
patterns = patterns_by_root[root]
spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
compiled_patterns[root] = spec
def is_excluded(path: Path) -> bool:
abspath = path.resolve()
root = root.resolve()
try:
relpath = pathlib.Path(os.path.relpath(abspath, start=root))
except ValueError:
# We get here if path and exclude_root are on different drives (on Windows).
# Things should still behave roughly as expected without using a relative
# path. See test_cli_utils.py::test_exclude_filter_windows for test cases.
relpath = abspath
for root, exclude_spec in compiled_patterns.items():
try:
relpath = Path(os.path.relpath(abspath, start=root))
except ValueError:
# We get here if path and exclude_root are on different drives (on
# Windows).Things should still behave roughly as expected without
# using a relative path. See
# test_cli_utils.py::test_exclude_filter_windows for test cases.
relpath = abspath
if exclude_spec.match_file(relpath):
return True
if exclude_spec.match_file(relpath):
return True
return False
return is_excluded
def resolve_sources(
paths: List[pathlib.Path],
exclude_patterns: List[str],
exclude_root: pathlib.Path,
extensions: List[str],
) -> List[Optional[pathlib.Path]]:
"""Resolves paths passed via CLI to a list of filepaths to lint.
`paths` is a list of paths that may be files or directories. Files are
returned verbatim if they exist, and directories are recursively searched
for files that have an extension specified in `extensions`. Paths that match a
pattern in `exclude_patterns` are ignored (based on gitignore pattern
format, see https://git-scm.com/docs/gitignore#_pattern_format).
Raises FileNotFoundError if a supplied path does not exist.
"""
extensions = [f".{ext}" if not ext.startswith(".") else ext for ext in extensions]
is_excluded = make_exclude_filter(exclude_patterns)
sources: List[Optional[pathlib.Path]] = []
for path in paths:
if str(path) == "-":
sources.append(None)
continue
if not path.exists():
raise FileNotFoundError(f"path {path} does not exist")
if is_excluded(path, exclude_root):
continue
if not path.is_dir():
sources.append(path)
continue
for dirpath, _, filenames in os.walk(path):
for name in filenames:
_, ext = os.path.splitext(name)
if ext.lower() in extensions:
child = pathlib.Path(dirpath) / name
if not is_excluded(child, exclude_root):
sources.append(child)
return sources