diff --git a/src/robocop/__init__.py b/src/robocop/__init__.py index c605d8dc7..aec8089cc 100644 --- a/src/robocop/__init__.py +++ b/src/robocop/__init__.py @@ -1 +1 @@ -__version__ = "7.3.0" # x-release-please-version +__version__ = "7.2.0" # x-release-please-version diff --git a/src/robocop/config.py b/src/robocop/config.py index 5cff41a6e..9f38ee2aa 100644 --- a/src/robocop/config.py +++ b/src/robocop/config.py @@ -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. @@ -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: diff --git a/src/robocop/config_manager.py b/src/robocop/config_manager.py index bf6802e98..9191028c8 100644 --- a/src/robocop/config_manager.py +++ b/src/robocop/config_manager.py @@ -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) @@ -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) @@ -174,39 +169,54 @@ 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. @@ -214,7 +224,7 @@ def get_config_for_source_file(self, source_file: Path) -> Config: """ 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, @@ -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) @@ -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 diff --git a/tests/config/test_config_manager.py b/tests/config/test_config_manager.py index ed9696f42..be0352fc0 100644 --- a/tests/config/test_config_manager.py +++ b/tests/config/test_config_manager.py @@ -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") @@ -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. " @@ -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 @@ -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"),