Skip to content

Commit 0e7f04e

Browse files
authored
feat: refactor source file handling (#1613)
* feat: Refactor source file and versioning handling Upcoming features requires us to refactor some of our core modules. They are now more OOP friendly and some of the duplicated code from tool merging is now removed as well. * test: add manual trigger for perfomance tests for PR with label performance
1 parent eea1c56 commit 0e7f04e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+802
-842
lines changed

.github/workflows/release_check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020

2121
performance-tests:
2222
runs-on: ubuntu-latest
23-
if: "contains(github.event.pull_request.labels.*.name, 'autorelease: pending')"
23+
if: "contains(github.event.pull_request.labels.*.name, 'autorelease: pending') || contains(github.event.pull_request.labels.*.name, 'performance')"
2424
steps:
2525
- uses: actions/checkout@v4
2626
- uses: astral-sh/setup-uv@v4

src/robocop/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "7.2.0" # x-release-please-version
1+
__version__ = "7.3.0" # x-release-please-version

src/robocop/config.py

Lines changed: 6 additions & 257 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,16 @@
99

1010
from robot.errors import DataError
1111

12-
from robocop.linter.utils.misc import ROBOT_VERSION
13-
1412
try:
1513
from robot.api import Languages # RF 6.0
1614
except ImportError:
1715
Languages = None
18-
import pathspec
1916
import typer
2017
from typing_extensions import Self
2118

22-
from robocop import exceptions, files
23-
from robocop.cache import RobocopCache
19+
from robocop import exceptions
2420
from robocop.formatter import formatters
2521
from robocop.formatter.skip import SkipConfig
26-
from robocop.formatter.utils import misc # TODO merge with linter misc
2722
from robocop.linter import rules
2823
from robocop.linter.rules import (
2924
AfterRunChecker,
@@ -32,17 +27,15 @@
3227
RuleSeverity,
3328
)
3429
from robocop.linter.utils.misc import compile_rule_pattern
35-
from robocop.linter.utils.version_matching import Version
30+
from robocop.version_handling import ROBOT_VERSION, Version
3631

37-
CONFIG_NAMES = frozenset(("robocop.toml", "pyproject.toml", "robot.toml"))
3832
DEFAULT_INCLUDE = frozenset(("*.robot", "*.resource"))
3933
DEFAULT_EXCLUDE = frozenset((".direnv", ".eggs", ".git", ".svn", ".hg", ".nox", ".tox", ".venv", "venv", "dist"))
4034

4135
DEFAULT_ISSUE_FORMAT = "{source}:{line}:{col} [{severity}] {rule_id} {desc} ({name})"
4236

4337
if TYPE_CHECKING:
4438
import re
45-
from collections.abc import Generator
4639

4740
from robocop.linter.rules import Rule
4841

@@ -167,10 +160,10 @@ def validate_target_version(value: str | TargetVersion | None) -> int | None:
167160
raise typer.BadParameter(
168161
f"Invalid target Robot Framework version: '{value}' is not one of {versions}"
169162
) from None
170-
if target_version > misc.ROBOT_VERSION.major:
163+
if target_version > ROBOT_VERSION.major:
171164
raise typer.BadParameter(
172165
f"Target Robot Framework version ({target_version}) should not be higher than "
173-
f"installed version ({misc.ROBOT_VERSION})."
166+
f"installed version ({ROBOT_VERSION})."
174167
) from None
175168
return target_version
176169

@@ -442,7 +435,7 @@ class FormatterConfig:
442435
configure: list[str] | None = field(default_factory=list)
443436
force_order: bool | None = False
444437
allow_disabled: bool | None = False
445-
target_version: int | str | None = field(default=misc.ROBOT_VERSION.major, compare=False)
438+
target_version: int | str | None = field(default=ROBOT_VERSION.major, compare=False)
446439
skip_config: SkipConfig = field(default_factory=SkipConfig)
447440
overwrite: bool | None = None
448441
diff: bool | None = False
@@ -720,7 +713,7 @@ class Config:
720713
languages: Languages | None = field(default=None, compare=False)
721714
verbose: bool | None = field(default_factory=bool)
722715
silent: bool | None = field(default_factory=bool)
723-
target_version: int | str | None = misc.ROBOT_VERSION.major
716+
target_version: int | str | None = ROBOT_VERSION.major
724717
_hash: int | None = None
725718
config_source: str = "cli"
726719

@@ -892,247 +885,3 @@ def hash(self) -> str:
892885
hasher.update(language_str.encode("utf-8"))
893886
self._hash = hasher.hexdigest()
894887
return self._hash
895-
896-
897-
class GitIgnoreResolver:
898-
def __init__(self):
899-
self.cached_ignores: dict[Path, list[pathspec.PathSpec] | None] = {}
900-
self.ignore_dirs: set[Path] = set()
901-
902-
def path_excluded(self, path: Path, gitignores: list[tuple[Path, pathspec.PathSpec]]) -> bool:
903-
"""Find path gitignores and check if file is excluded."""
904-
if not gitignores:
905-
return False
906-
for gitignore_path, gitignore in gitignores:
907-
relative_path = files.get_relative_path(path, gitignore_path)
908-
path = str(relative_path)
909-
# fixes a bug in pathspec where directory needs to end with / to be ignored by pattern
910-
if relative_path.is_dir() and path != ".":
911-
path = f"{path}{os.sep}"
912-
if gitignore.match_file(path):
913-
return True
914-
return False
915-
916-
def read_gitignore(self, path: Path) -> pathspec.PathSpec:
917-
"""Return a PathSpec loaded from the file."""
918-
with path.open(encoding="utf-8") as gf:
919-
lines = gf.readlines()
920-
return pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, lines)
921-
922-
def resolve_path_ignores(self, path: Path) -> list[tuple[Path, pathspec.PathSpec]]:
923-
"""
924-
Visit all parent directories and find all gitignores.
925-
926-
Gitignores are cached for multiple sources.
927-
928-
Args:
929-
path: path to file/directory
930-
931-
Returns:
932-
PathSpec from merged gitignores.
933-
934-
"""
935-
# TODO: respect nogitignore flag
936-
if path.is_file():
937-
path = path.parent
938-
gitignores = []
939-
search_paths = (parent for parent in [path, *path.parents])
940-
for parent_path in search_paths:
941-
if parent_path in self.ignore_dirs: # dir that does not have .gitignore (marked as such)
942-
gitignores.extend([self.cached_ignores[path] for path in search_paths if path in self.cached_ignores])
943-
break
944-
if parent_path in self.cached_ignores:
945-
gitignores.append(self.cached_ignores[parent_path])
946-
# if any parent is cached, we can retrieve any parent with gitignore from cache and return early
947-
gitignores.extend([self.cached_ignores[path] for path in search_paths if path in self.cached_ignores])
948-
break
949-
if (gitignore_path := parent_path / ".gitignore").is_file():
950-
gitignore = self.read_gitignore(gitignore_path)
951-
self.cached_ignores[parent_path] = (parent_path, gitignore)
952-
gitignores.append((parent_path, gitignore))
953-
else:
954-
self.ignore_dirs.add(parent_path)
955-
if (parent_path / ".git").is_dir():
956-
break
957-
return gitignores
958-
959-
960-
class ConfigManager:
961-
"""
962-
Finds and loads configuration files for each file.
963-
964-
Config provided from cli takes priority. ``--config`` option overrides any found configuration file.
965-
"""
966-
967-
def __init__(
968-
self,
969-
sources: list[str] | None = None,
970-
config: Path | None = None,
971-
root: str | None = None,
972-
ignore_git_dir: bool = False,
973-
ignore_file_config: bool = False,
974-
skip_gitignore: bool = False,
975-
force_exclude: bool = False,
976-
overwrite_config: Config | None = None,
977-
):
978-
"""
979-
Initialize ConfigManager.
980-
981-
Args:
982-
sources: List of sources with Robot Framework files.
983-
config: Path to configuration file.
984-
root: Root of the project. Can be supplied if it's known beforehand (for example by IDE plugin)
985-
Otherwise it will be automatically found.
986-
ignore_git_dir: Flag for project root discovery to decide if directories with `.git` should be ignored.
987-
ignore_file_config: If set to True, Robocop will not load found configuration files
988-
skip_gitignore: Do not load .gitignore files when looking for the files to parse
989-
force_exclude: Enforce exclusions, even for paths passed directly in the command-line
990-
overwrite_config: Overwrite existing configuration file with the Config class
991-
992-
"""
993-
self.cached_configs: dict[Path, Config] = {}
994-
self.overwrite_config = overwrite_config
995-
self.ignore_git_dir = ignore_git_dir
996-
self.ignore_file_config = ignore_file_config
997-
self.force_exclude = force_exclude
998-
self.skip_gitignore = skip_gitignore
999-
self.gitignore_resolver = GitIgnoreResolver()
1000-
self.overridden_config = (
1001-
config is not None
1002-
) # TODO: what if both cli and --config? should take --config then apply cli
1003-
self.root = root or Path.cwd()
1004-
self.sources = sources
1005-
self.default_config: Config = self.get_default_config(config)
1006-
self._paths: dict[Path, Config] | None = None
1007-
self._cache: RobocopCache | None = None
1008-
1009-
@property
1010-
def cache(self) -> RobocopCache:
1011-
"""Get the file cache, initializing it lazily if needed."""
1012-
if self._cache is None:
1013-
cache_config = self.default_config.cache
1014-
self._cache = RobocopCache(
1015-
cache_dir=cache_config.cache_dir if cache_config else None,
1016-
enabled=cache_config.enabled if cache_config else True,
1017-
verbose=self.default_config.verbose or False,
1018-
)
1019-
return self._cache
1020-
1021-
@property
1022-
def paths(self) -> Generator[tuple[Path, Config], None, None]:
1023-
# TODO: what if we provide the same path twice - tests
1024-
if self._paths is None:
1025-
self._paths = {}
1026-
sources = self.sources if self.sources else self.default_config.sources
1027-
ignore_file_filters = not self.force_exclude and bool(sources)
1028-
self.resolve_paths(sources, ignore_file_filters=ignore_file_filters)
1029-
yield from self._paths.items()
1030-
1031-
def get_default_config(self, config_path: Path | None) -> Config:
1032-
"""Get default config either from --config option or from the cli."""
1033-
if config_path:
1034-
configuration = files.read_toml_config(config_path)
1035-
config = Config.from_toml(configuration, config_path.resolve())
1036-
elif not self.ignore_file_config:
1037-
sources = [Path(path).resolve() for path in self.sources] if self.sources else [Path.cwd()]
1038-
directories = files.get_common_parent_dirs(sources)
1039-
config = self.find_config_in_dirs(directories, default=None)
1040-
if not config:
1041-
config = Config()
1042-
self.cached_configs.update(dict.fromkeys(directories, config))
1043-
else:
1044-
config = Config()
1045-
config.overwrite_from_config(self.overwrite_config)
1046-
return config
1047-
1048-
def is_git_project_root(self, path: Path) -> bool:
1049-
"""Check if current directory contains .git directory and might be a project root."""
1050-
if self.ignore_git_dir:
1051-
return False
1052-
return (path / ".git").is_dir()
1053-
1054-
def find_closest_config(self, source: Path, default: Config | None) -> Config:
1055-
"""Look in the directory and its parents for the closest valid configuration file."""
1056-
return self.find_config_in_dirs(source.parents, default)
1057-
1058-
def find_config_in_dirs(self, directories: list[Path], default: Config | None) -> Config:
1059-
seen = [] # if we find config, mark all visited directories with resolved config
1060-
for check_dir in directories:
1061-
if check_dir in self.cached_configs:
1062-
return self.cached_configs[check_dir]
1063-
seen.append(check_dir)
1064-
for config_filename in CONFIG_NAMES:
1065-
if (config_path := (check_dir / config_filename)).is_file():
1066-
configuration = files.read_toml_config(config_path)
1067-
if configuration:
1068-
config = Config.from_toml(configuration, config_path)
1069-
config.overwrite_from_config(self.overwrite_config) # TODO those two lines together
1070-
self.cached_configs.update(dict.fromkeys(seen, config))
1071-
if config.verbose:
1072-
print(f"Loaded {config_path} configuration file.")
1073-
return config
1074-
if self.is_git_project_root(check_dir):
1075-
break
1076-
1077-
if default:
1078-
self.cached_configs.update(dict.fromkeys(seen, default))
1079-
return default
1080-
1081-
def get_config_for_source_file(self, source_file: Path) -> Config:
1082-
"""
1083-
Find the closest config to the source file or directory.
1084-
1085-
If it was loaded before it will be returned from the cache. Otherwise, we will load it and save it to cache
1086-
first.
1087-
1088-
Args:
1089-
source_file: Path to Robot Framework source file or directory.
1090-
1091-
"""
1092-
if self.overridden_config or self.ignore_file_config:
1093-
return self.default_config
1094-
return self.find_closest_config(source_file, self.default_config)
1095-
1096-
def resolve_paths(
1097-
self,
1098-
sources: list[str | Path],
1099-
ignore_file_filters: bool = False,
1100-
) -> None:
1101-
"""
1102-
Find all files to parse and their corresponding configs.
1103-
1104-
Initially sources can be ["."] (if not path provided, assume current working directory).
1105-
It can be also any list of paths, for example ["tests/", "file.robot"].
1106-
1107-
Args:
1108-
sources: list of sources from CLI or configuration file.
1109-
gitignores: list of gitignore pathspec and their locations for path resolution.
1110-
ignore_file_filters: force robocop to parse file even if it's excluded in the configuration
1111-
1112-
"""
1113-
source_gitignore = None
1114-
config = None
1115-
for source in sources:
1116-
source_not_resolved = Path(source)
1117-
source = source_not_resolved.resolve()
1118-
if source in self._paths:
1119-
continue
1120-
if not source.exists():
1121-
if source_not_resolved.is_symlink(): # i.e. dangling symlink
1122-
continue
1123-
raise exceptions.FatalError(f"File '{source}' does not exist")
1124-
if config is None: # first file in the directory
1125-
config = self.get_config_for_source_file(source)
1126-
if not ignore_file_filters:
1127-
if config.file_filters.path_excluded(source_not_resolved):
1128-
continue
1129-
if source.is_file() and not config.file_filters.path_included(source_not_resolved):
1130-
continue
1131-
if not self.skip_gitignore:
1132-
source_gitignore = self.gitignore_resolver.resolve_path_ignores(source_not_resolved)
1133-
if self.gitignore_resolver.path_excluded(source_not_resolved, source_gitignore):
1134-
continue
1135-
if source.is_dir():
1136-
self.resolve_paths(source.iterdir())
1137-
elif source.is_file():
1138-
self._paths[source] = config

0 commit comments

Comments
 (0)