Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/robocop/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "7.3.0" # x-release-please-version
__version__ = "7.2.0" # x-release-please-version
8 changes: 6 additions & 2 deletions src/robocop/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,7 @@ def load_languages(self):
raise typer.Exit(code=1) from None

@classmethod
def from_toml(cls, config: dict, config_path: Path) -> Config:
def from_toml(cls, config: dict, config_path: Path, overwrite_config: Config | None) -> Config:
"""
Load configuration from toml dict.

Expand All @@ -767,7 +767,11 @@ def from_toml(cls, config: dict, config_path: Path) -> Config:
normalize_config_keys(config.pop("format", {})), config_path.parent
)
parsed_config = {key: value for key, value in parsed_config.items() if value is not None}
return cls(**parsed_config)
config = cls(**parsed_config)
config.overwrite_from_config(overwrite_config)
if config.verbose:
print(f"Loaded {config_path} configuration file.")
return config

@staticmethod
def validate_config(config: dict, config_path: Path) -> None:
Expand Down
70 changes: 39 additions & 31 deletions src/robocop/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,7 @@ def __init__(
self.force_exclude = force_exclude
self.skip_gitignore = skip_gitignore
self.gitignore_resolver = GitIgnoreResolver()
self.overridden_config = (
config is not None
) # TODO: what if both cli and --config? should take --config then apply cli
self.overridden_config = config is not None
self.root = root or Path.cwd()
self.sources = sources
self.default_config: Config = self.get_default_config(config)
Expand Down Expand Up @@ -155,14 +153,11 @@ def get_default_config(self, config_path: Path | None) -> Config:
"""Get default config either from --config option or from the cli."""
if config_path:
configuration = files.read_toml_config(config_path)
config = Config.from_toml(configuration, config_path.resolve())
elif not self.ignore_file_config:
return Config.from_toml(configuration, config_path.resolve(), overwrite_config=self.overwrite_config)
if not self.ignore_file_config:
sources = [Path(path).resolve() for path in self.sources] if self.sources else [Path.cwd()]
directories = files.get_common_parent_dirs(sources)
config = self.find_config_in_dirs(directories, default=None)
if not config:
config = Config()
self.cached_configs.update(dict.fromkeys(directories, config))
else:
config = Config()
config.overwrite_from_config(self.overwrite_config)
Expand All @@ -174,47 +169,62 @@ def is_git_project_root(self, path: Path) -> bool:
return False
return (path / ".git").is_dir()

def find_closest_config(self, source: Path, default: Config | None) -> Config:
"""Look in the directory and its parents for the closest valid configuration file."""
return self.find_config_in_dirs(source.parents, default)
def find_config_in_directory(self, directory: Path) -> Config | None:
"""
Search for a configuration file in the specified directory.

This method iterates through predefined configuration filenames and attempts
to locate and load the first matching configuration file found in the given
directory. Only configuration files with valid Robocop entry are taken into account.

Args:
directory: The directory path to search for configuration files.

Returns:
A Config object if a configuration file is found and successfully
loaded, otherwise None.

"""
for config_filename in CONFIG_NAMES:
if (config_path := (directory / config_filename)).is_file() and (
configuration := files.read_toml_config(config_path)
):
return Config.from_toml(configuration, config_path, overwrite_config=self.overwrite_config)
return None

def find_config_in_dirs(self, directories: list[Path], default: Config | None) -> Config:
seen = [] # if we find config, mark all visited directories with resolved config
found = default
for check_dir in directories:
if check_dir in self.cached_configs:
return self.cached_configs[check_dir]
found = self.cached_configs[check_dir]
break
seen.append(check_dir)
for config_filename in CONFIG_NAMES:
if (config_path := (check_dir / config_filename)).is_file():
configuration = files.read_toml_config(config_path)
if configuration:
config = Config.from_toml(configuration, config_path)
config.overwrite_from_config(self.overwrite_config) # TODO those two lines together
self.cached_configs.update(dict.fromkeys(seen, config))
if config.verbose:
print(f"Loaded {config_path} configuration file.")
return config
if (directory_config := self.find_config_in_directory(check_dir)) is not None:
found = directory_config
break
if self.is_git_project_root(check_dir):
break

if default:
self.cached_configs.update(dict.fromkeys(seen, default))
return default
if found is None:
found = Config()
self.cached_configs.update(dict.fromkeys(seen, found))
return found

def get_config_for_source_file(self, source_file: Path) -> Config:
"""
Find the closest config to the source file or directory.

If it was loaded before it will be returned from the cache. Otherwise, we will load it and save it to cache
first.
If it was loaded before, it will be returned from the cache. Otherwise, we will
load it and save it to the cache first.

Args:
source_file: Path to Robot Framework source file or directory.

"""
if self.overridden_config or self.ignore_file_config:
return self.default_config
return self.find_closest_config(source_file, self.default_config)
return self.find_config_in_dirs(source_file.parents, self.default_config)

def resolve_paths(
self,
Expand All @@ -233,7 +243,6 @@ def resolve_paths(
ignore_file_filters: force robocop to parse file even if it's excluded in the configuration

"""
source_gitignore = None
config = None
for source in sources:
source_not_resolved = Path(source)
Expand All @@ -244,8 +253,7 @@ def resolve_paths(
if source_not_resolved.is_symlink(): # i.e. dangling symlink
continue
raise exceptions.FatalError(f"File '{source}' does not exist")
if config is None: # first file in the directory
config = self.get_config_for_source_file(source)
config = self.get_config_for_source_file(source)
if not ignore_file_filters:
if config.file_filters.path_excluded(source_not_resolved):
continue
Expand Down
35 changes: 31 additions & 4 deletions tests/config/test_config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def cli_config_path(test_data) -> Path:
@pytest.fixture(scope="module")
def cli_config(cli_config_path) -> Config:
configuration = files.read_toml_config(cli_config_path)
return Config.from_toml(configuration, cli_config_path)
return Config.from_toml(configuration, cli_config_path, overwrite_config=None)


@pytest.fixture(scope="module")
Expand Down Expand Up @@ -303,12 +303,39 @@ def test_relative_paths_from_config_option(self, test_data):
== (relative_parent / "custom_formatters").resolve()
)

def test_multiple_sources_and_configs(self, tmp_path):
"""Multiple source paths passed to the entry command, with different configuration files."""
# Arrange - create two test files with two closest configurations
config_file_1 = tmp_path / "pyproject.toml"
config_file_1.write_text("[tool.robocop]\nverbose = true\n", encoding="utf-8")
first_config = Config()
first_config.verbose = True
first_config.config_source = str(config_file_1)
test_file_1 = tmp_path / "test.robot"
test_file_1.write_text("*** Test Cases ***\nTest\n Log Hello\n", encoding="utf-8")

config_file_2 = tmp_path / "subdir" / "pyproject.toml"
config_file_2.parent.mkdir(parents=True, exist_ok=True)
config_file_2.write_text("[tool.robocop]\nverbose = false\n", encoding="utf-8")
second_config = Config()
second_config.verbose = False
second_config.config_source = str(config_file_2)
test_file_2 = tmp_path / "subdir" / "test.robot"
test_file_2.write_text("*** Test Cases ***\nTest\n Log Hello\n", encoding="utf-8")
expected_results = {test_file_1: first_config, test_file_2: second_config}

# Act
actual_results = get_sources_and_configs(tmp_path, sources=[test_file_1, test_file_2])

# Assert
assert actual_results == expected_results

def test_fail_on_deprecated_config_options(self, test_data, capsys):
"""Unknown or deprecated options in the configuration file should raise an error."""
config_path = test_data / "old_config" / "pyproject.toml"
configuration = files.read_toml_config(config_path)
with pytest.raises(typer.Exit):
Config.from_toml(configuration, config_path)
Config.from_toml(configuration, config_path, overwrite_config=None)
out, _ = capsys.readouterr()
assert (
f"Configuration file seems to use Robocop < 6.0.0 or Robotidy syntax. "
Expand All @@ -320,7 +347,7 @@ def test_fail_on_unknown_config_options(self, test_data, capsys):
config_path = test_data / "invalid_config" / "invalid.toml"
configuration = files.read_toml_config(config_path)
with pytest.raises(typer.Exit):
Config.from_toml(configuration, config_path)
Config.from_toml(configuration, config_path, overwrite_config=None)
out, _ = capsys.readouterr()
assert f"Unknown configuration key: 'unknown' in {config_path}" in out

Expand Down Expand Up @@ -362,7 +389,7 @@ def test_invalid_option_value(self, test_data):
typer.BadParameter,
match="Invalid target Robot Framework version: '100' is not one of",
):
Config.from_toml(configuration, config_path)
Config.from_toml(configuration, config_path, overwrite_config=None)

@pytest.mark.parametrize(
("force_exclude", "skip_gitignore", "should_exclude_file"),
Expand Down