Skip to content

Commit bdcfd48

Browse files
authored
fix: multiple paths passed to robocop check/format command resolving to the same config (#1614)
Fixed a bug where config was not correctly resolved if sources were directly passed to robocop. First found configuration was used instead of searching for config for all passed sources.
1 parent 0e7f04e commit bdcfd48

File tree

4 files changed

+77
-38
lines changed

4 files changed

+77
-38
lines changed

src/robocop/__init__.py

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

src/robocop/config.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,7 @@ def load_languages(self):
743743
raise typer.Exit(code=1) from None
744744

745745
@classmethod
746-
def from_toml(cls, config: dict, config_path: Path) -> Config:
746+
def from_toml(cls, config: dict, config_path: Path, overwrite_config: Config | None) -> Config:
747747
"""
748748
Load configuration from toml dict.
749749
@@ -767,7 +767,11 @@ def from_toml(cls, config: dict, config_path: Path) -> Config:
767767
normalize_config_keys(config.pop("format", {})), config_path.parent
768768
)
769769
parsed_config = {key: value for key, value in parsed_config.items() if value is not None}
770-
return cls(**parsed_config)
770+
config = cls(**parsed_config)
771+
config.overwrite_from_config(overwrite_config)
772+
if config.verbose:
773+
print(f"Loaded {config_path} configuration file.")
774+
return config
771775

772776
@staticmethod
773777
def validate_config(config: dict, config_path: Path) -> None:

src/robocop/config_manager.py

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,7 @@ def __init__(
120120
self.force_exclude = force_exclude
121121
self.skip_gitignore = skip_gitignore
122122
self.gitignore_resolver = GitIgnoreResolver()
123-
self.overridden_config = (
124-
config is not None
125-
) # TODO: what if both cli and --config? should take --config then apply cli
123+
self.overridden_config = config is not None
126124
self.root = root or Path.cwd()
127125
self.sources = sources
128126
self.default_config: Config = self.get_default_config(config)
@@ -155,14 +153,11 @@ def get_default_config(self, config_path: Path | None) -> Config:
155153
"""Get default config either from --config option or from the cli."""
156154
if config_path:
157155
configuration = files.read_toml_config(config_path)
158-
config = Config.from_toml(configuration, config_path.resolve())
159-
elif not self.ignore_file_config:
156+
return Config.from_toml(configuration, config_path.resolve(), overwrite_config=self.overwrite_config)
157+
if not self.ignore_file_config:
160158
sources = [Path(path).resolve() for path in self.sources] if self.sources else [Path.cwd()]
161159
directories = files.get_common_parent_dirs(sources)
162160
config = self.find_config_in_dirs(directories, default=None)
163-
if not config:
164-
config = Config()
165-
self.cached_configs.update(dict.fromkeys(directories, config))
166161
else:
167162
config = Config()
168163
config.overwrite_from_config(self.overwrite_config)
@@ -174,47 +169,62 @@ def is_git_project_root(self, path: Path) -> bool:
174169
return False
175170
return (path / ".git").is_dir()
176171

177-
def find_closest_config(self, source: Path, default: Config | None) -> Config:
178-
"""Look in the directory and its parents for the closest valid configuration file."""
179-
return self.find_config_in_dirs(source.parents, default)
172+
def find_config_in_directory(self, directory: Path) -> Config | None:
173+
"""
174+
Search for a configuration file in the specified directory.
175+
176+
This method iterates through predefined configuration filenames and attempts
177+
to locate and load the first matching configuration file found in the given
178+
directory. Only configuration files with valid Robocop entry are taken into account.
179+
180+
Args:
181+
directory: The directory path to search for configuration files.
182+
183+
Returns:
184+
A Config object if a configuration file is found and successfully
185+
loaded, otherwise None.
186+
187+
"""
188+
for config_filename in CONFIG_NAMES:
189+
if (config_path := (directory / config_filename)).is_file() and (
190+
configuration := files.read_toml_config(config_path)
191+
):
192+
return Config.from_toml(configuration, config_path, overwrite_config=self.overwrite_config)
193+
return None
180194

181195
def find_config_in_dirs(self, directories: list[Path], default: Config | None) -> Config:
182196
seen = [] # if we find config, mark all visited directories with resolved config
197+
found = default
183198
for check_dir in directories:
184199
if check_dir in self.cached_configs:
185-
return self.cached_configs[check_dir]
200+
found = self.cached_configs[check_dir]
201+
break
186202
seen.append(check_dir)
187-
for config_filename in CONFIG_NAMES:
188-
if (config_path := (check_dir / config_filename)).is_file():
189-
configuration = files.read_toml_config(config_path)
190-
if configuration:
191-
config = Config.from_toml(configuration, config_path)
192-
config.overwrite_from_config(self.overwrite_config) # TODO those two lines together
193-
self.cached_configs.update(dict.fromkeys(seen, config))
194-
if config.verbose:
195-
print(f"Loaded {config_path} configuration file.")
196-
return config
203+
if (directory_config := self.find_config_in_directory(check_dir)) is not None:
204+
found = directory_config
205+
break
197206
if self.is_git_project_root(check_dir):
198207
break
199208

200-
if default:
201-
self.cached_configs.update(dict.fromkeys(seen, default))
202-
return default
209+
if found is None:
210+
found = Config()
211+
self.cached_configs.update(dict.fromkeys(seen, found))
212+
return found
203213

204214
def get_config_for_source_file(self, source_file: Path) -> Config:
205215
"""
206216
Find the closest config to the source file or directory.
207217
208-
If it was loaded before it will be returned from the cache. Otherwise, we will load it and save it to cache
209-
first.
218+
If it was loaded before, it will be returned from the cache. Otherwise, we will
219+
load it and save it to the cache first.
210220
211221
Args:
212222
source_file: Path to Robot Framework source file or directory.
213223
214224
"""
215225
if self.overridden_config or self.ignore_file_config:
216226
return self.default_config
217-
return self.find_closest_config(source_file, self.default_config)
227+
return self.find_config_in_dirs(source_file.parents, self.default_config)
218228

219229
def resolve_paths(
220230
self,
@@ -233,7 +243,6 @@ def resolve_paths(
233243
ignore_file_filters: force robocop to parse file even if it's excluded in the configuration
234244
235245
"""
236-
source_gitignore = None
237246
config = None
238247
for source in sources:
239248
source_not_resolved = Path(source)
@@ -244,8 +253,7 @@ def resolve_paths(
244253
if source_not_resolved.is_symlink(): # i.e. dangling symlink
245254
continue
246255
raise exceptions.FatalError(f"File '{source}' does not exist")
247-
if config is None: # first file in the directory
248-
config = self.get_config_for_source_file(source)
256+
config = self.get_config_for_source_file(source)
249257
if not ignore_file_filters:
250258
if config.file_filters.path_excluded(source_not_resolved):
251259
continue

tests/config/test_config_manager.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def cli_config_path(test_data) -> Path:
3333
@pytest.fixture(scope="module")
3434
def cli_config(cli_config_path) -> Config:
3535
configuration = files.read_toml_config(cli_config_path)
36-
return Config.from_toml(configuration, cli_config_path)
36+
return Config.from_toml(configuration, cli_config_path, overwrite_config=None)
3737

3838

3939
@pytest.fixture(scope="module")
@@ -303,12 +303,39 @@ def test_relative_paths_from_config_option(self, test_data):
303303
== (relative_parent / "custom_formatters").resolve()
304304
)
305305

306+
def test_multiple_sources_and_configs(self, tmp_path):
307+
"""Multiple source paths passed to the entry command, with different configuration files."""
308+
# Arrange - create two test files with two closest configurations
309+
config_file_1 = tmp_path / "pyproject.toml"
310+
config_file_1.write_text("[tool.robocop]\nverbose = true\n", encoding="utf-8")
311+
first_config = Config()
312+
first_config.verbose = True
313+
first_config.config_source = str(config_file_1)
314+
test_file_1 = tmp_path / "test.robot"
315+
test_file_1.write_text("*** Test Cases ***\nTest\n Log Hello\n", encoding="utf-8")
316+
317+
config_file_2 = tmp_path / "subdir" / "pyproject.toml"
318+
config_file_2.parent.mkdir(parents=True, exist_ok=True)
319+
config_file_2.write_text("[tool.robocop]\nverbose = false\n", encoding="utf-8")
320+
second_config = Config()
321+
second_config.verbose = False
322+
second_config.config_source = str(config_file_2)
323+
test_file_2 = tmp_path / "subdir" / "test.robot"
324+
test_file_2.write_text("*** Test Cases ***\nTest\n Log Hello\n", encoding="utf-8")
325+
expected_results = {test_file_1: first_config, test_file_2: second_config}
326+
327+
# Act
328+
actual_results = get_sources_and_configs(tmp_path, sources=[test_file_1, test_file_2])
329+
330+
# Assert
331+
assert actual_results == expected_results
332+
306333
def test_fail_on_deprecated_config_options(self, test_data, capsys):
307334
"""Unknown or deprecated options in the configuration file should raise an error."""
308335
config_path = test_data / "old_config" / "pyproject.toml"
309336
configuration = files.read_toml_config(config_path)
310337
with pytest.raises(typer.Exit):
311-
Config.from_toml(configuration, config_path)
338+
Config.from_toml(configuration, config_path, overwrite_config=None)
312339
out, _ = capsys.readouterr()
313340
assert (
314341
f"Configuration file seems to use Robocop < 6.0.0 or Robotidy syntax. "
@@ -320,7 +347,7 @@ def test_fail_on_unknown_config_options(self, test_data, capsys):
320347
config_path = test_data / "invalid_config" / "invalid.toml"
321348
configuration = files.read_toml_config(config_path)
322349
with pytest.raises(typer.Exit):
323-
Config.from_toml(configuration, config_path)
350+
Config.from_toml(configuration, config_path, overwrite_config=None)
324351
out, _ = capsys.readouterr()
325352
assert f"Unknown configuration key: 'unknown' in {config_path}" in out
326353

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

367394
@pytest.mark.parametrize(
368395
("force_exclude", "skip_gitignore", "should_exclude_file"),

0 commit comments

Comments
 (0)